# JustScale - Full documentation > A TypeScript backend framework where plain, straight-line code just scales - durable processes and an ID-free domain built in. Full text of the JustScale documentation, comparisons, and migration guides, concatenated for LLM ingestion. Linked index: https://justscale.sh/llms.txt --- # Debugging URL: https://justscale.sh/docs/advanced/debugging Debugging Advanced debugging tools, tracing, and logging strategies JustScale provides comprehensive debugging tools including custom debugger formatters, distributed tracing support, and structured logging. The @justscale/debugger package enhances your debugging experience in JetBrains IDEs and Chrome DevTools. Enhanced Debugging with @justscale/debugger The debugger package provides custom object formatters and inspector proxies that make debugging JustScale applications significantly easier. Installation Bash ```bash pnpm add --save-dev @justscale/debugger ``` Usage with JetBrains IDEs Enable custom formatters in WebStorm, IntelliJ IDEA, or other JetBrains IDEs by adding the debugger setup to your Node options. Bash ```bash # Option A: Automatic spawn wrapper (recommended for JetBrains) # In Run/Debug Configuration > Node options: --import @justscale/debugger/setup # Option B: Bypass JetBrains injection (simpler) # In Run/Debug Configuration > Node options: --inspect=9229 --import @justscale/debugger/setup # Option C: Terminal/programmatic npx tsx --import @justscale/debugger/setup src/index.ts ``` How It Works The debugger package creates an inspector proxy that intercepts the Chrome DevTools Protocol and enables custom object formatters: Detects JetBrains debugger injection and spawns a clean child process Opens Node inspector on an internal port (default: 9339) Creates a proxy on the debugger port (default: 9229) Intercepts Runtime.enable and injects setCustomObjectFormatterEnabled(true) Transforms customPreview responses into description fields Programmatic Setup You can also set up the inspector proxy programmatically: debug.tsTypeScript ```typescript import { setupInspectorProxy } from "@justscale/debugger"; import JustScale from "@justscale/core"; // Set up at the start of your application await setupInspectorProxy({ inspectorPort: 9339, // Internal inspector port waitForDebugger: false, // Wait for debugger to attach verbose: true, // Enable logging }); // Your app code const cluster = JustScale().build(); await app.serve({ http: 3000 }); ``` Custom Object Formatters With the debugger enabled, you can define custom formatters for your objects to improve the debugging experience: formatters.tsTypeScript ```typescript // Define custom formatter for User objects if (typeof globalThis.devtoolsFormatters === "undefined") { globalThis.devtoolsFormatters = []; } globalThis.devtoolsFormatters.push({ header(obj: any) { if (!obj || typeof obj !== "object") return null; if (!("__userBrand" in obj)) return null; // Return JsonML format return ["div", {}, `User(${obj.email})`]; }, hasBody() { return true; }, body(obj: any) { return [ "div", {}, ["div", {}, `ID: ${obj.id}`], ["div", {}, `Email: ${obj.email}`], ["div", {}, `Role: ${obj.role}`], ]; }, }); ``` Structured Logging JustScale provides a built-in structured logging system with context propagation and observability integration. Using the Logger Every route handler receives a logger instance with automatic context (controller name, route, request ID). src/controllers/users.tsTypeScript ```typescript import { createController } from "@justscale/core"; import { Get } from '@justscale/http'; import { UserService } from "../services/user-service"; const UsersController = createController("/users", { inject: { users: UserService }, routes: (services) => ({ list: Get("/").handle(({ logger, res }) => { logger.info("Fetching all users"); const result = services.users.findAll(); logger.debug("Found users", { count: result.length }); res.json({ users: result }); }), }), }); // Output: // [INFO] [/users] Fetching all users { requestId: "a1b2c3d4" } // [DEBUG] [/users] Found users { count: 42, requestId: "a1b2c3d4" } ``` Logger Methods MethodLevelUse Case logger.debug(msg, attrs?)DEBUGDetailed diagnostic information logger.info(msg, attrs?)INFOGeneral informational messages logger.warn(msg, attrs?)WARNWarning messages for unexpected situations logger.error(msg, attrs?)ERRORError conditions that need attention Custom Logger Factory Override the default console logger with your own implementation: pino-logger.tsTypeScript ```typescript import { Container } from "@justscale/core"; import type { Logger, LoggerFactory, LogLevel, LogAttributes } from "@justscale/core"; import pino from "pino"; class PinoLoggerFactory implements LoggerFactory { private pino = pino(); create(context: string): Logger { return { debug: (message: string, attributes?: LogAttributes) => { this.pino.debug({ context, ...attributes }, message); }, info: (message: string, attributes?: LogAttributes) => { this.pino.info({ context, ...attributes }, message); }, warn: (message: string, attributes?: LogAttributes) => { this.pino.warn({ context, ...attributes }, message); }, error: (message: string, attributes?: LogAttributes) => { this.pino.error({ context, ...attributes }, message); }, }; } } // Register in your container const container = new Container(); container.setLoggerFactory(new PinoLoggerFactory()); ``` Distributed Tracing JustScale includes built-in support for distributed tracing through observability context propagation and instrumentation hooks. Observability Context Every request runs within an observability scope that propagates context through async operations: context-example.tsTypeScript ```typescript import { getContext, runWithContext } from "@justscale/core"; import { createController } from "@justscale/core"; import { Get } from "@justscale/http"; const UsersController = createController("/users", { routes: () => ({ getOne: Get("/:id").handle(async ({ params, res }) => { // Get current context const ctx = getContext(); console.log(ctx); // { // requestId: "a1b2c3d4", // route: "getUser", // method: "GET", // path: "/users/:id" // } // Run code in a nested scope await runWithContext( { userId: params.id }, async () => { const nested = getContext(); console.log(nested); // { // requestId: "a1b2c3d4", // route: "getUser", // method: "GET", // path: "/users/:id", // userId: "123" // } } ); res.json({ user: { id: params.id } }); }), }), }); ``` Instrumentation Hooks Integrate with OpenTelemetry, Datadog, or other observability tools by registering instrumentation hooks: Files srcinstrumentationopentelemetry.ts index.ts srcinstrumentationopentelemetry.ts index.ts src/instrumentation/opentelemetry.tsTypeScript ```typescript import { registerInstrumentation } from "@justscale/core"; import type { Instrumentation, ScopeInfo, ObservabilityContext } from "@justscale/core"; import { trace } from "@opentelemetry/api"; const OpenTelemetryInstrumentation: Instrumentation = { name: "opentelemetry", onScopeStart(info: ScopeInfo, context: ObservabilityContext) { const tracer = trace.getTracer("justscale"); const span = tracer.startSpan(info.name, { attributes: { ...info.attributes, ...context, }, }); // Store span in context for later return { span }; }, onScopeEnd(info: ScopeInfo, context: ObservabilityContext, data?: any) { if (data?.span) { data.span.end(); } }, onScopeError(info: ScopeInfo, context: ObservabilityContext, error: Error, data?: any) { if (data?.span) { data.span.recordException(error); data.span.setStatus({ code: 2 }); // ERROR data.span.end(); } }, }; // Register the instrumentation registerInstrumentation(OpenTelemetryInstrumentation); ``` Automatic Request Tracing JustScale automatically creates a trace scope for each request with HTTP method, route, and request ID: internal-tracing.tsTypeScript ```typescript import { runInScopeAsync } from "@justscale/core"; // Internally, every request runs in a scope like this: await runInScopeAsync( { type: "request", name: `${route.method} ${route.path}`, attributes: { "http.method": route.method, "http.route": route.path, "route.name": route.name, }, }, { requestId: "a1b2c3d4", route: route.name, method: route.method, path: route.path, }, async () => { // Middleware, guards, handler all run here // Context is automatically propagated } ); ``` Debugging Strategies Service Resolution Issues If you encounter dependency resolution errors, check: All services are registered in the correct order Circular dependencies are avoided (use lazy resolution if needed) Service tokens match exactly (import from the same file) TypeScript compilation succeeds (type errors indicate missing deps) di-debugging.tsTypeScript ```typescript import { Container } from "@justscale/core"; // Enable verbose DI logging const container = new Container(); // Register with logging container.register(MyService); console.log("Registered:", container.has(MyService)); // Resolve with logging try { const instance = container.resolve(MyService); console.log("Resolved:", instance); } catch (error) { console.error("Resolution failed:", error); } ``` Route Matching Issues Debug route matching by inspecting the compiled routes: route-debugging.tsTypeScript ```typescript import JustScale from "@justscale/core"; const app = JustScale() .add(UsersController) .build(); // Inspect compiled routes for (const controller of app.controllers) { console.log(`Controller: ${controller.prefix}`); for (const route of controller.routes) { console.log( ` ${route.method} ${route.path} (pattern: ${route.pattern})` ); } } // Test route matching const matched = app.match("GET", "/users/123"); if (matched) { console.log("Matched route:", matched.route.name); console.log("Params:", matched.params); } else { console.log("No route matched"); } ``` Middleware Execution Order Add logging middleware to trace execution order: middleware-debugging.tsTypeScript ```typescript import { createMiddleware } from "@justscale/core"; import { Get } from "@justscale/http"; const LoggingMiddleware = createMiddleware({ inject: {}, handler: () => async (ctx) => { console.log("[MIDDLEWARE] Before handler", { route: ctx.logger, params: ctx.params, }); // Return empty object (adds nothing to context) return {}; }, }); // Use in routes Get("/users/:id") .use(LoggingMiddleware) .use(parseAuth) .handle(({ user, params, res }) => { console.log("[HANDLER] Executing"); res.json({ user }); }); // Output: // [MIDDLEWARE] Before handler // [AUTH] Validating token // [HANDLER] Executing ``` Cluster Communication Issues Debug cluster socket communication: Bash ```bash # Check if socket exists ls -la /tmp/justscale/ # Test socket connection justscale --help # Enable verbose logging DEBUG=justscale:* node src/index.ts ``` Performance Profiling Use Node.js built-in profiling tools with JustScale: Bash ```bash # CPU profiling node --cpu-prof --import @justscale/debugger/setup src/index.ts # Heap snapshot node --heap-prof --import @justscale/debugger/setup src/index.ts # Inspect with Chrome DevTools node --inspect --import @justscale/debugger/setup src/index.ts # Open chrome://inspect in Chrome ``` Add performance marks in your code: performance-profiling.tsTypeScript ```typescript import { performance } from "perf_hooks"; import { createController } from "@justscale/core"; import { Get } from "@justscale/http"; const PerformanceController = createController("/api", { routes: () => ({ slow: Get("/slow").handle(({ logger, res }) => { performance.mark("handler-start"); // Do expensive work const result = expensiveOperation(); performance.mark("handler-end"); performance.measure("handler", "handler-start", "handler-end"); const measure = performance.getEntriesByName("handler")[0]; logger.info("Handler performance", { duration: measure.duration }); res.json({ result, duration: measure.duration }); }), }), }); ``` ℹ️Info Pro Tip: The @justscale/debugger package's inspector proxy is completely transparent - your app doesn't need any code changes. Just add the import flag and debug as usual! Next Steps Plugins Type Utilities Testing --- # Hot Module Replacement URL: https://justscale.sh/docs/advanced/hmr Hot Module Replacement Edit services, controllers, and models without restarting the process HMR is on by default in development. When you run just dev, the @justscale/hmr/registerloader hooks Node's module resolution, watches the workspace, and hot-swaps modules in place on every save. Nothing to wire up in your own code. What Survives, What Rebuilds HMR reloads the module graph that changed and rebinds it to the running container. Services, controllers, models, and their route handlers swap to the new implementation without a process restart. Module-scoped state in a separate file keeps its value across swaps. Files state.tscounter-service.ts state.tscounter-service.ts state.tsTypeScript ```typescript // Module-scoped state — survives HMR swaps of the service file below export let counter = 0; export const bump = () => ++counter; ``` Rebuilt on save:the edited file's module and anything downstream in the DI graph. Preserved:the process itself, open sockets, module-scoped state in files you didn't touch, in-flight requests that started before the swap. Not preserved:per-instance state inside a service factory — that's the point of a rebuild. Keep anything worth keeping in a separate state module, or in a real store. Adding New Wiring Without Restart HMR isn't just for editing existing code. Adding a fresh .add(NewService) or .add(NewController) to your app factory is picked up on the next save: the new definition lands in the live DI container, controllers get resolved against it, routes light up. The process keeps running, existing state keeps its value, in-flight requests finish against the old graph. TypeScript ```ts // src/app.ts export default defineApp(import.meta, (env: AppEnv) => JustScale() .add(env) .add(GreetingService) .add(GreetingController), ); ``` Save the file after adding .add(AdminController) at the end of the chain: Bash ```bash [hmr] src/app.ts changed — bumping 3 url(s) in dep chain [hmr] rebuilding — entry …/src/app.ts @ v=1776780742716 [hmr] added controller src/admin.controller.ts#AdminController (routes=1) [hmr] rebuild complete in 9ms — replaced=0 added=1 removed=0 ``` The controller's inject list is resolved against the live container, so a newly-wired controller that depends on a service registered at boot (or in the same edit) connects to the same singleton the rest of the app already uses — no fresh instance, no split state. One-time stable-ID cost. The first save that introduces a module logs added. Every subsequent save of the same file is a normal replaced (factory swap in place) — identical performance to editing a service that existed from boot. Services get registered only. A service with no consumers stays dormant on the live container; nothing resolves it until a controller or another service pulls it in. Broken grafts are refused. If the new .add(X) introduces an unsatisfied dependency (say X needs a service you forgot to also add), the rebuild's .build()validator rejects the graph, logs exactly what's missing, and leaves the live app untouched. No half-wired state. Bash ```bash [hmr] src/app.ts changed — bumping 3 url(s) in dep chain [hmr] rebuild failed during build(): DependencyError: Missing dependencies: ParentService requires: - ChildService ``` 💡Tip Set HMR_VERBOSE=1 before just devto trace the rebuild step-by-step: which stable IDs were collected, which were new vs. known, whether each new one was actually registered in the new build's DI graph. Useful when an edit doesn't propagate the way you expected. Limits (Honest List) Removal is a no-op today. If you delete .add(X) from the chain, X's instance keeps running and its routes keep answering until the next process restart. Matters most for services that own timers, intervals, or open connections — those don't get shutdown-on-remove. Safe for pure-function services. Planned. Durable process definitions (*.process.ts) have a more conservative rule: HMR swaps the service def, but any execution already suspended mid-flight keeps using its original compiled state machine. A process started before an edit completes on the old code; new executions use the new code. Intentional — yanking an opcode out from under a live workflow is worse than a slightly stale run. Syntax Errors Are Non-Fatal A broken save does not crash the dev process. The old handler keeps serving traffic while HMR logs the failure. Fix the typo, save again, and the next good build replaces the handler. You do not need to restart just dev. Bash ```bash $ just dev [hmr] watching workspace # ... edit a file with a syntax error ... [hmr] rebuild failed: services/user-service.ts (1:14): unexpected token [hmr] keeping previous handler # ... fix and save ... [hmr] reloaded services/user-service.ts ``` 💡Tip If a rebuild fails, the error line tells you which file and where. The last working handler stays active, so the app keeps responding while you fix it. Testing HMR The HMR package ships an e2e harness for tests that need to exercise the real reload loop — a live child process, real HTTP calls, and a per-run temporary copy of a fixture directory so mutations don't leak between tests. my-hmr.test.tsTypeScript ```typescript import { test } from 'node:test'; import assert from 'node:assert/strict'; import { startFixture } from '@justscale/hmr/test/e2e/harness'; test('handler reloads on edit', async () => { const app = await startFixture({ fixtureDir: './fixtures/basic' }); try { assert.equal(await app.json('/hello'), 'hello'); // edit() writes the file and waits for HMR to settle await app.edit('src/hello.ts', (src) => src.replace('hello', 'hola'), ); assert.equal(await app.json('/hello'), 'hola'); } finally { await app.shutdown(); } }); ``` The harness copies fixtureDir into a temp path per run, spawns a child just dev against the copy, and gives you helpers to edit files and wait for HMR. A suite of example tests covering method-swap, route add/remove, controller and service add, missing-dep rejection, and syntax-error recovery lives in packages/feature/hmr/test/e2e/ — lift whichever pattern fits. Next Steps Debugging Testing CLI Usage --- # Plugins URL: https://justscale.sh/docs/advanced/plugins Plugins Extend JustScale with custom transports and plugins JustScale's plugin system allows you to extend the framework with custom route factories, transports, and integrations. The core framework is transport-agnostic - HTTP, gRPC, WebSockets, and other protocols are implemented as plugins. Understanding the Plugin System JustScale uses TypeScript's module augmentation and a registry-based approach to extend functionality. Plugins can: Add new route factory methods (like Get, Post, etc.) Extend the route context with transport-specific properties Register as cluster transports for protocol-specific serving Hook into the app lifecycle for initialization Creating Custom Route Factories Route factories provide the DSL for defining routes. The HTTP plugin provides Get, Post, etc. You can create your own. Files srcpluginssse.ts controllersevents.ts srcpluginssse.ts controllersevents.ts src/plugins/sse.tsTypeScript ```typescript import type { RouteHandler, RouteMethod } from "@justscale/core/plugin"; import { registerRouteFactory } from "@justscale/core/plugin"; // Step 1: Augment the RouteFactories interface declare module "@justscale/core/plugin" { interface RouteFactories { // Add your custom route factory method SSE(path: string): SseRouteBuilder; } } // Step 2: Create the factory implementation function createSseFactory() { return function SSE( path: TPath ): SseRouteBuilder { return { handle(handler) { return { method: "GET", path, middlewares: [], guards: [], handler: async (ctx) => { // Set up SSE headers ctx.res.setHeader("Content-Type", "text/event-stream"); ctx.res.setHeader("Cache-Control", "no-cache"); ctx.res.setHeader("Connection", "keep-alive"); // Create event stream const stream = new EventStream(ctx.res); await handler({ ...ctx, stream }); }, }; }, }; }; } // Register the factory registerRouteFactory("SSE", createSseFactory()); ``` Extending Route Context Plugins can add transport-specific properties to route handlers by augmenting the RouteContext interface. http-context.tsTypeScript ```typescript import type { ServerResponse, IncomingMessage } from "http"; declare module "@justscale/core/plugin" { interface RouteContext { // Add HTTP-specific properties res: ServerResponse; req: IncomingMessage; // These are now available in all route handlers } } ``` The HTTP plugin uses this to add res and req. Your handlers can then access these properties with full type safety. Creating Cluster Transports Cluster transports integrate with JustScale().build() to handle protocol-specific serving. The HTTP transport is a good example. Files srcpluginsgrpc-transport.ts index.ts srcpluginsgrpc-transport.ts index.ts src/plugins/grpc-transport.tsTypeScript ```typescript import { registerClusterTransport } from "@justscale/core/cluster"; import type { ClusterTransportPlugin } from "./types"; const GrpcTransport: ClusterTransportPlugin = { name: "grpc", beforeControllerResolution(container, controllers) { // Register gRPC-specific services // e.g., reflection service, interceptors }, onAppCreated(app) { // Optional: post-creation setup }, async serve(app, config) { const server = new GrpcServer(); // Register routes from app.controllers for (const controller of app.controllers) { for (const route of controller.routes) { server.register(route); } } await server.listen(config.port); return { stop: async () => { await server.close(); }, }; }, }; // Register the transport registerClusterTransport("grpc", GrpcTransport); ``` Real-World Example: HTTP Plugin The @justscale/http package is a complete plugin implementation. Here's how it's structured: http-plugin.tsTypeScript ```typescript import { registerRouteFactory } from "@justscale/core/plugin"; import { registerClusterTransport } from "@justscale/core/cluster"; import { Get, Post, Put, Delete, Patch } from "./factory"; import { HttpTransport } from "./transport"; // 1. Augment route factories declare module "@justscale/core/plugin" { interface RouteFactories { Get: HttpFactory; Post: HttpFactory; Put: HttpFactory; Delete: HttpFactory; Patch: HttpFactory; } } // 2. Augment route context declare module "@justscale/core/plugin" { interface RouteContext { res: JsonResponse; req: IncomingMessage; } } // 3. Register factories registerRouteFactory("Get", Get); registerRouteFactory("Post", Post); registerRouteFactory("Put", Put); registerRouteFactory("Delete", Delete); registerRouteFactory("Patch", Patch); // 4. Register as cluster transport registerClusterTransport("http", HttpTransport); ``` Plugin Best Practices Use module augmentation for type safety - Always augment interfaces rather than using any Register early - Import and register plugins before creating your app Namespace your additions - Prefix custom context properties to avoid conflicts Document your plugin - Provide clear examples and type definitions Test in isolation - Use createStandaloneApp for plugin testing ℹ️Info Pro Tip: Study the source code of @justscale/http and @justscale/datastar for complete plugin examples. They demonstrate route factories, context augmentation, and cluster transport integration. Next Steps Type Utilities Debugging Request Handling --- # TypeScript Utilities URL: https://justscale.sh/docs/advanced/types TypeScript Utilities Advanced TypeScript patterns and utilities in JustScale JustScale leverages TypeScript's advanced type system to provide compile-time guarantees. Understanding these type utilities helps you build more robust applications and create custom abstractions. Dependency Type Utilities JustScale provides utilities for working with dependencies and their resolved instances. InstanceOf and ResolvedDeps These utilities extract instance types from service tokens and convert dependency maps to resolved instances: src/example.tsTypeScript ```typescript import type { InstanceOf, ResolvedDeps } from "@justscale/core"; import { defineService } from "@justscale/core"; import { UserService } from "./services/user-service"; // InstanceOf: Extract instance type from a service token type UserServiceInstance = InstanceOf; // => { get: (ref: Ref) => Promise, ... } // Works with classes too class DatabaseService { query(sql: string) { /* ... */ } } type DbInstance = InstanceOf; // => DatabaseService // ResolvedDeps: Convert dependency map to resolved instances const deps = { users: UserService, db: DatabaseService, }; type Resolved = ResolvedDeps; // => { // users: UserServiceInstance, // db: DatabaseService, // } // Used internally by defineService class MyService extends defineService({ inject: { users: UserService, db: DatabaseService }, factory: (deps) => { // deps has type ResolvedDeps<{ users: ..., db: ... }> deps.users.get(User.ref`123`); return { /* ... */ }; }, }) {} ``` ExtractDeps and ExtractAllDeps Extract direct and transitive dependencies from services: deps-example.tsTypeScript ```typescript import type { ExtractDeps, ExtractAllDeps } from "@justscale/core"; import { defineService } from "@justscale/core"; // Database has no dependencies class Database extends defineService({ inject: {}, factory: () => ({}) }) {} // UserService depends on Database class UserService extends defineService({ inject: { db: Database }, factory: ({ db }) => ({}), }) {} // AuthService depends on UserService (and transitively on Database) class AuthService extends defineService({ inject: { users: UserService }, factory: ({ users }) => ({}), }) {} // ExtractDeps: Get direct dependencies only type UserServiceDeps = ExtractDeps; // => { db: typeof Database } // ExtractAllDeps: Get all transitive dependencies type AllDeps = ExtractAllDeps; // => typeof UserService | typeof Database ``` Route Context Types Route handlers receive a context object with dependencies, parameters, and transport-specific properties. Understanding these types helps with type-safe middleware and handlers. HandlerContext The full context type available in route handlers. Combines dependencies, transport context, and built-in properties. src/controllers/users.tsTypeScript ```typescript import type { HandlerContext } from "@justscale/core"; import { createController } from "@justscale/core"; import { Get } from '@justscale/http'; import { UserService } from "../services/user-service"; import { AuthService } from "../services/auth-service"; const UsersController = createController("/users", { inject: { users: UserService, auth: AuthService }, routes: (services) => ({ list: Get("/").handle((ctx) => { // ctx has type: // HandlerContext<{ users: UserService, auth: AuthService }> // // Which expands to: // { // users: UserServiceInstance, // auth: AuthServiceInstance, // params: Record, // logger: Logger, // res: JsonResponse, // from HTTP plugin // req: IncomingMessage, // // ... other transport properties // } ctx.users.findAll(); ctx.logger.info("Listing users"); ctx.res.json({ users: [] }); }), }), }); ``` ExtractParams and Prettify Type utilities for path parameter extraction and type cleaning: type-utils.tsTypeScript ```typescript import type { ExtractParams, Prettify } from "@justscale/core"; import { createController } from "@justscale/core"; import { Get } from '@justscale/http'; // ExtractParams: Extract path parameters from route strings type Params1 = ExtractParams<"/users/:id">; // => { id: string } type Params2 = ExtractParams<"/users/:userId/posts/:postId">; // => { userId: string, postId: string } type Params3 = ExtractParams<"/api/v1/items">; // => {} (no params) // Used automatically in route handlers const UsersController = createController('/users', { routes: () => ({ getOne: Get('/:id').handle(({ params }) => { params.id; // TypeScript knows this exists! }), }), }); // Prettify: Flatten intersection types for better tooltips type Complex = { a: string } & { b: number } & { c: boolean }; // Tooltip shows: { a: string } & { b: number } & { c: boolean } type Clean = Prettify; // Tooltip shows: { a: string, b: number, c: boolean } ``` Middleware Type Utilities Middleware types help ensure type-safe context accumulation. Middleware and MiddlewareDef Type-safe middleware with and without dependency injection: Files srcmiddlewareauth.ts controllersprofile.ts srcmiddlewareauth.ts controllersprofile.ts src/middleware/auth.tsTypeScript ```typescript import type { Middleware, MiddlewareDef } from "@justscale/core"; import { createMiddleware } from "@justscale/core"; import { TokenService } from "../services/token-service"; // Simple middleware without dependencies const parseAuth: Middleware< { req: IncomingMessage }, { user: User } > = async (ctx) => { const token = ctx.req.headers.authorization; const user = await validateToken(token); return { user }; }; // Middleware with dependency injection export const AuthMiddleware: MiddlewareDef< { user: User }, { tokens: TokenService } > = createMiddleware({ inject: { tokens: TokenService }, handler: ({ tokens }) => async (ctx) => { const user = await tokens.validate(ctx.req.headers.authorization); return { user }; }, }); ``` Feature Type Utilities Features use advanced types to handle dependency graphs and configuration. PendingFeature and ResolvedFeatureDeps Type utilities for working with features and their dependencies: features.tsTypeScript ```typescript import { createFeatureBuilder } from "@justscale/core"; import { ModelRepository } from "@justscale/core/models"; // FeatureToken: result of createFeatureBuilder()...provides() // Tracks required tokens and provided tokens at the type level const AuthFeature = createFeatureBuilder() .name('auth') .requires(ModelRepository.of(User)) .requires(ModelRepository.of(Session)) .requires(AbstractEmailSender) .provides((b) => b .add(PasswordService) .add(UserService) .add(SessionService) .add(AuthController) ); // AuthFeature is a FeatureToken // TypeScript tracks what it needs and what it provides: // TRequires = [ModelRepository, ModelRepository, AbstractEmailSender] // TProvides = [PasswordService, UserService, SessionService, AuthController] // Features that require other features get their provides available const AdminFeature = createFeatureBuilder() .name('admin') .requires(AuthFeature) // UserService, SessionService, etc. now available .provides((b) => b .add(AdminService) // Can depend on AuthFeature's services .add(AdminController) ); // In the app, dependencies are checked at compile time import JustScale from "@justscale/core"; const app = JustScale() .add(AuthFeature) .add(AdminFeature) // Type error if AuthFeature is missing .build(); ``` Validation Types JustScale uses type-level validation to catch errors at compile time. ValidateDeps and ValidateDepsNoConflict Compile-time validation ensures dependencies are provided and don't conflict: validation-example.tsTypeScript ```typescript import JustScale, { defineService, createController } from "@justscale/core"; import { Get } from '@justscale/http'; class Database extends defineService({ inject: {}, factory: () => ({ query: async (sql: string) => [] }), }) {} class UserService extends defineService({ inject: { db: Database }, factory: ({ db }) => ({ findAll: async () => [] }), }) {} const UsersController = createController("/users", { inject: { users: UserService }, routes: (services) => ({ list: Get('/').handle(({ res }) => res.json({ users: [] })), }), }); // RequiresSatisfied (compile-time): builder rejects incomplete graphs const badApp = JustScale() .add(UsersController) // type error: UserService (and Database) not provided yet .build(); // Correct: add dependencies before the dependents const goodApp = JustScale() .add(Database) .add(UserService) .add(UsersController) .build(); // ValidateDepsNoConflict: This will error at compile time const BadController = createController("/bad", { inject: { users: UserService, res: ResponseService, // Error! 'res' is reserved }, routes: (services) => ({ /* ... */ }), }); // Error: Property '__context_conflict__' exists with conflicting: 'res' ``` Type Utilities Reference UtilityPurposePackage InstanceOfExtract instance type from service token@justscale/core ResolvedDepsMap dependency tokens to instances@justscale/core ExtractDepsGet dependencies from service/controller@justscale/core ExtractAllDepsGet transitive dependencies@justscale/core HandlerContextFull route handler context type@justscale/core ExtractParamsExtract params from path string@justscale/core PrettifyFlatten intersection types@justscale/core Building Type-Safe Abstractions Use these utilities to build your own type-safe wrappers and helpers. cached-service.tsTypeScript ```typescript import type { InstanceOf, ResolvedDeps } from "@justscale/core"; import { defineService } from "@justscale/core"; // Type-safe service factory wrapper function createCachedService< TDeps extends Record, TInstance >(config: { inject: TDeps; factory: (deps: ResolvedDeps) => TInstance; ttl?: number; }) { const cache = new Map(); return defineService({ inject: config.inject, factory: (deps) => { const instance = config.factory(deps); // Wrap methods with caching logic return new Proxy(instance, { /* ... */ }); }, }); } // Use it class Database extends defineService({ inject: {}, factory: () => ({ query: async (sql: string, params: any[]) => { return [{ id: "1", name: "Alice" }]; }, }), }) {} const CachedUserService = createCachedService({ inject: { db: Database }, factory: ({ db }) => ({ get: (ref: Ref) => db.query(`SELECT * FROM users WHERE id = ?`, [ref.id]), }), ttl: 60000, }); // Fully typed, with caching! ``` ℹ️Info Type Safety First: JustScale's type utilities enable compile-time dependency validation, eliminating entire classes of runtime errors. The framework fails fast during development, not in production. Next Steps Plugins Debugging Services --- # Why It Scales URL: https://justscale.sh/docs/advanced/why-it-scales Why It Scales The mechanical chain that turns a one-instance program into a distributed one "It just scales" is a slogan. This page is the proof. JustScale turns a program written for one process into a program that runs correctly across many — not by hiding coordination in a framework runtime, but by refusing to compile code that would be wrong under coordination. The type system is the enforcement layer. The adapters are the execution layer. Domain code is written once and is correct in both. What follows is the four mechanical rules that make this work, followed by the actual end-to-end test that proves it on two real Node processes sharing a Postgres database. Rule 1 — Mutations require Locked Every mutating method on the repository demands proof of a lock in its signature. The type signature is the contract: repository.tsTypeScript ```typescript abstract class Repository { // Reads: no lock required abstract get(ref: Ref): Promise | null> abstract findOne(where: Partial): Promise | null> // Writes: Locked required. There is no overload. abstract update(entity: Locked, patch: UpdateData): Promise> abstract save(entity: Transient | Locked): Promise> abstract delete(entity: Locked): Promise } ``` The only way to obtain a Locked is to call repo.lock(ref). TypeScript will not let you construct one by hand, cast to one, or receive one from anywhere outside the lock API. This is a closed contract: if your code compiles, every write passed through a lock. Rule 2 — repo.lock() is atomic with the read Acquiring a lock is not a two-step "fetch, then lock" dance. On Postgres it is a single statement — SELECT ... FOR UPDATE — that returns the locked row's current contents: pg-repository.tsTypeScript ```typescript // packages/adapters/postgres/src/repository/pg-repository.ts async lock(entity: Ref): Promise | null> { const id = extractId(entity) // Row-level lock + fresh read in ONE statement. // No other session can modify this row until we release. const result = await sql` SELECT * FROM ${sql(this.tableName)} WHERE id = ${id} FOR UPDATE ` if (result.length === 0) return null // The Locked is built from the row we just read under the lock. // Whatever the caller passed in is irrelevant to the returned state. return brandLocked(this.rowToEntity(result[0])) } ``` 💡Tip The invariant this establishes: every Locked in your program contains data that is authoritative as of the moment the lock was acquired. No other process can have modified it since — no other session can hold the lock concurrently. Combined with Rule 1, the conclusion is strong: in a JustScale app, every write happens on data that was re-read atomically with the lock that protects it. There is no optimistic-concurrency version column, no retry loop, no CAS — because there is no race to resolve. The moment a writer has the lock, the writer has the truth. The in-memory provider implements the same contract with a mutex plus a closure-local map read; the pg provider uses advisory locks plus FOR UPDATE. Domain code cannot tell the difference. Rule 3 — Locked cannot cross process boundaries A lock is an async-context fact on a specific node. Sending one across the network would be a lie. The framework enforces this at the serializer: processable-encoder.tsTypeScript ```typescript // The encoder refuses Locked in any signal payload or // cross-process value. A lock guarantee is local; shipping // it would produce a brand that no remote session can honor. if (isLocked(value)) { throw new ProcessableEncodeError( 'Locked cannot be serialized across processes. ' + 'Convert to Reference via Model.ref(locked) first.', ) } ``` This turns a subtle correctness trap into a compile-adjacent error. You will learn at the edge of the wire — not in production, three weeks later, after a silent divergence — that you tried to smuggle a local guarantee across a boundary. The fix is always the same: Model.ref(locked) unwraps to a Reference, which is wire-safe; the receiver re-acquires the lock on their own process if they need to mutate. Rule 4 — Signals carry typed identity, not free-form payloads Cross-process coordination in JustScale travels on signals. A signal is not an arbitrary event — it is a path with typed parameters: fulfillment.signals.tsTypeScript ```typescript export class FulfillmentSignals extends defineSignals(signal => ({ shipped: signal('/shipment/:shipment/shipped') .types({ shipment: Shipment }), delivered: signal('/shipment/:shipment/delivered') .types({ shipment: Shipment }), })) {} ``` The path is the topic on the pg NOTIFY bus. The typed params are the routing key. defineSignals rejects duplicate path params at definition time, and emission throws if a path parameter is missing at runtime. Every signal that can be defined can be routed; every signal that is routed carries exactly the identity needed to deliver it. Signals with Locked path params are unwrapped to Referenceautomatically before emission — the caller's lock stays on the sending node; receivers get a reference and re-acquire a fresh lock under Rule 2 if they need to mutate. The proof: two real processes, one database The chat-app example ships a cross-process end-to-end test that exercises all four rules. It spawns two actual Node processes — not workers, not threads — each running the production just dev entrypoint bound to a different port but pointed at the same Postgres database: multi-process.e2e.test.tsTypeScript ```typescript // Two real child_process.spawn() instances on :6301 and :6302. // JUSTSCALE_NO_SOCKET=1 disables the cluster unix socket, so A and B // cannot coordinate via local IPC — the ONLY path between them is pg. const proc = spawn(JUST_BIN, ['dev'], { env: { ...process.env, PORT: String(port), DATABASE_URL: DB_URL, // shared pg database SIGNAL_CHANNEL, // shared pg NOTIFY channel JUSTSCALE_NO_SOCKET: '1', // no shortcut }, }) // ...after boot: it('cross-process chat: A <-> B via pg LISTEN/NOTIFY + advisory locks', async () => { const alice = await register(PORT_A, 'alice@proc.test', ...) const bob = await register(PORT_B, 'bob@proc.test', ...) const room = await createRoom(PORT_A, alice.token, 'proc-room') await joinRoom(PORT_B, bob.token, room.id) // Alice opens a WebSocket on A. Bob opens a WebSocket on B. const aliceWs = await openRoomWs(PORT_A, alice.token, room.id) const bobWs = await openRoomWs(PORT_B, bob.token, room.id) // Alice posts on A. Bob's WS on B receives the message. aliceWs.socket.send(JSON.stringify({ type: 'post', text: 'hello from A' })) const onB = await waitForMessage(bobWs, m => m.type === 'message' && m.data.text === 'hello from A', 10_000) assert.ok(onB, "bob on :6302 never received alice's message from :6301") }) ``` What this proves, concretely: No shared memory. Two OS processes, separate heaps. No shortcuts. The cluster unix socket is disabled with JUSTSCALE_NO_SOCKET=1. Every coordination path between A and B must travel through Postgres. Domain code is unchanged. The chat controllers, services, and signals are the same code that runs in the single-process tests. Nothing in src/ knows about multi-process deployment. All four rules are exercised. Every message send locks the room under Rule 1 and Rule 2; the broadcast signal travels under Rules 3 and 4 via pg LISTEN/NOTIFY. The test runs in roughly 800ms. It is the practical answer to "does this actually scale?". What the framework does NOT force Honesty matters. The four rules above close the write path. The remaining distributed-system footguns live on the read path and in user-held caches, and the framework cannot forbid them at the type level without making every program miserable to write: Reads are not locked. repo.findOne returns a Persistent without coordinating with writers. That is the right default — locking every read would be a performance disaster — but it means an unlocked read may observe state that is being mutated by another process. If you branch on that read and then lock, Rule 2 guarantees your write is on fresh data, but the decision to write may have been made on a stale snapshot. For decisions that must be made under lock, lock first. Caches in service closures are allowed. A service can stash a Map>and return from it. Nothing in the type system objects. Convention (and the "no module-level state" rule that JustScale's DI pattern nudges you toward) catches this in review; the compiler doesn't. Caches can poison read paths; they cannot poison mutation paths because the mutation path always re-enters lock(). Raw adapter access bypasses everything. If you reach past the repository API and execute raw SQL against the pg client, you can do anything you like. That is a deliberate hostile act, not an accident the framework should prevent. The framework's claim is narrow and strong: every write in a JustScale app that goes through the repository API is correct under any number of coordinated processes.Reads and caches remain the developer's judgment call — because for most reads, coordination would be pure overhead. Summary text ```text Rule 1. repo.update/save/delete require Locked [type-checked] Rule 2. repo.lock() is atomic SELECT ... FOR UPDATE [pg adapter] Rule 3. Locked cannot serialize across processes [encoder throws] Rule 4. signals carry typed identity on the path [defineSignals] Consequence: every write in a JustScale app runs against data that was re-read under the lock that protects it, on any number of processes, against any supported adapter. The domain code does not change when you add nodes. ``` That is what "just scales" means here. Not a marketing claim — a provable property of the type system plus the adapter contracts, demonstrated end-to-end by a test that spawns two real processes. Next Steps Locks Philosophy Durable Processes --- # CLI Usage URL: https://justscale.sh/docs/cli/usage CLI Usage Build command-line interfaces with JustScale Overview The CLI transport allows you to expose controller methods as command-line commands. Routes defined with Cli() become executable commands with automatic argument parsing, validation, and rich terminal output. Basic CLI Route Create a CLI route using the Cli() factory in your controller: controllers/db.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Cli } from '@justscale/core/cli'; import { z } from 'zod'; export const DbController = createController('db', { routes: () => ({ migrate: Cli('migrate') .input(z.object({ direction: z.enum(['up', 'down']), steps: z.number().default(1), })) .handle(({ args, io }) => { io.log(`Running ${args.steps} migrations ${args.direction}...`); io.result({ applied: args.steps }); }), }), }); ``` This creates a command that can be run as: Bash ```bash # Using the just CLI just db migrate up --steps 3 ``` Arguments and Flags JustScale automatically parses arguments based on your Zod schema: Positional Arguments Required fields without defaults become positional arguments: create-user-command.tsTypeScript ```typescript import { Cli } from '@justscale/core/cli'; import { z } from 'zod'; const CreateUserArgs = z.object({ email: z.string().email(), name: z.string(), admin: z.boolean().default(false), }); Cli('create-user') .input(CreateUserArgs) .handle(({ args, io }) => { // Email and name are positional: create-user john@example.com "John Doe" // Admin is a flag: --admin io.log(`Creating user: ${args.name} (${args.email})`); }); ``` Named Flags Optional fields and fields with defaults become named flags: build-command.tsTypeScript ```typescript import { Cli } from '@justscale/core/cli'; import { z } from 'zod'; const BuildArgs = z.object({ src: z.string().default('./src'), out: z.string().default('./dist'), verbose: z.boolean().default(false), }); Cli('build') .input(BuildArgs) .handle(({ args, io }) => { // All are flags: build --src ./app --out ./build --verbose }); ``` Interactive Prompts Any required argument that is missing from the command line will auto-prompt when stdin is a TTY. A plain z.string()on a required field will produce a sensible default prompt (derived from the field name, or from the field's .meta({ description }) when present). You only need arg() when you want a password prompt, a re-entry confirmation, a custom label, or to mask input. interactive-command.tsTypeScript ```typescript import { Cli, arg } from '@justscale/core/cli'; import { z } from 'zod'; const CreateUserArgs = z.object({ // Missing? User is prompted: "email:" email: z.email(), // Missing? Prompted with secret input and confirmation re-entry. password: arg(z.string(), { prompt: 'Password', secret: true, confirm: true, }), }); ``` Descriptions & Help Text Two places to attach human-readable text: a one-line command summary via .describe() on the builder, and per-field metadata via zod v4's .meta({ description, examples }). Both feed --help, and field metadata also feeds validation-error messages when an argument is missing. user-add-command.tsTypeScript ```typescript import { Cli } from '@justscale/core/cli'; import { z } from 'zod'; Cli('user add') .describe('Create a new user account') .input(z.object({ email: z.email().meta({ description: 'User email address', examples: ['alice@example.com'], }), name: z.string().optional().meta({ description: 'Display name', }), })) .handle(({ args, io }) => { io.log(`Created ${args.email}`); }); ``` Top-level --help just --help groups commands by prefix (or by the first word of a multi-word command name) and aligns descriptions: Bash ```bash Usage: just [options] Commands: status Show runtime status migrate: up Apply pending migrations session: list List active sessions revoke Revoke all sessions for a user user: add Create a new user account list List all registered users Run 'just --help' for details on a command. ``` Per-command --help Field descriptions and examples surface on the individual command's help screen: Bash ```bash $ just user add --help Usage: just user add [--name ] Create a new user account Arguments: User email address example: "alice@example.com" Options: --name Display name ``` Validation errors When a required argument is missing (and stdin is not a TTY, so no prompt happens), the error uses the same description and example from .meta() — no raw zod output: Bash ```bash $ just user add error: missing argument: User email address () example: "alice@example.com" Usage: just user add [--name ] Run `just user add --help` for details. ``` The io Object The io object provides methods for terminal output and user interaction: Basic Output status-command.tsTypeScript ```typescript import { Cli } from '@justscale/core/cli'; Cli('status').handle(({ io }) => { io.log('Everything is working'); // Normal output io.warn('This might be a problem'); // Yellow warning io.error('Something went wrong'); // Red error to stderr io.debug('Verbose information'); // Only shown with --verbose }); ``` Progress Indicators deploy-command.tsTypeScript ```typescript import { Cli } from '@justscale/core/cli'; Cli('deploy').handle(async ({ io }) => { // Spinner for indeterminate operations const spinner = io.spinner('Deploying application...'); await performDeploy(); spinner.success('Deployment complete'); // Progress bar for tasks with known total const progress = io.progress('Uploading files', 100); for (let i = 0; i <= 100; i += 10) { progress.update(i, `Uploading file ${i/10}`); await uploadFile(i); } progress.complete(); }); ``` Interactive Input setup-command.tsTypeScript ```typescript import { Cli } from '@justscale/core/cli'; Cli('setup').handle(async ({ io }) => { // Text prompt const name = await io.prompt('What is your name?', 'Default Name'); // Confirmation const proceed = await io.confirm('Continue with installation?'); // Single selection const env = await io.select('Select environment', [ 'development', 'staging', 'production' ]); // Selection with labels const region = await io.select('Select region', [ { label: 'US East', value: 'us-east-1' }, { label: 'EU West', value: 'eu-west-1' }, ]); // Password (hidden input) const password = await io.password('Enter password'); }); ``` Tables list-users-command.tsTypeScript ```typescript import { Cli } from '@justscale/core/cli'; Cli('list-users').handle(async ({ io, users }) => { const allUsers = await users.findAll(); io.table(allUsers, ['id', 'email', 'createdAt']); // Or with custom columns io.table(allUsers, [ { key: 'id', header: 'ID', width: 8 }, { key: 'email', header: 'Email Address', width: 30 }, { key: 'createdAt', header: 'Created', align: 'right' }, ]); }); ``` Structured Results Use io.result() to return structured data for programmatic use: status-result-command.tsTypeScript ```typescript import { Cli } from '@justscale/core/cli'; import { z } from 'zod'; const StatusResult = z.object({ branch: z.string(), clean: z.boolean(), ahead: z.number(), }); Cli('status') .returns(0, StatusResult) .handle(({ io }) => { io.log('Checking git status...'); // Return structured data (validated against schema) io.result({ branch: 'main', clean: true, ahead: 0, }); }); ``` Running CLI Commands There are several ways to execute CLI commands: Using the app socket When just dev starts an app, a Unix socket is created that allows the just binary to invoke commands defined on your controllers: app.tsTypeScript ```typescript import JustScale, { defineApp } from '@justscale/core'; import type { AppEnv } from './env-contract'; import { DbController } from './controllers/db'; // defineApp is the canonical entrypoint. It opens the cluster socket // automatically; no manual app.serve() call needed. export default defineApp(import.meta, (env: AppEnv) => JustScale() .add(env) .add(DbController) ); ``` Bash ```bash # In another terminal: just db migrate up --steps 3 just db seed --file ./data.json ``` Programmatic Invocation Invoke commands programmatically using invoke(): invoke-example.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { invoke } from '@justscale/core/cli'; import { DbController } from './controllers/db'; const app = JustScale() .add(DbController) .build(); const result = await invoke(app, 'db migrate', { direction: 'up', steps: 3, }); ``` Direct Execution Run commands directly from argv using run(): cli.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { run } from '@justscale/core/cli'; import { DbController } from './controllers/db'; const app = JustScale() .add(DbController) .build(); // This parses process.argv and executes the command await run(app, { name: 'myapp', exitOnError: true, }); ``` Bash ```bash # Run with tsx or node: tsx cli.ts db migrate up --steps 3 ``` Tab Completion The first time you run any just ...command in dev mode, JustScale installs a shell completion function into your shell's rc file. No manual setup, no eval line to paste. The install is idempotent — a # justscale:tab-completion marker keeps it from duplicating on repeat runs. Supported shells bash — appended to ~/.bashrc zsh — appended to ~/.zshrc fish — written to ~/.config/fish/completions/just.fish The shell is detected from $SHELL. The completion function is inlined directly — no external script to keep in sync. Per-project commands Completion candidates come from the project in your current working directory. The installed shell function shells out to just __complete , which resolves commands from the cwd's project. Two projects, two different completion sets: Bash ```bash cd app-a && just # shows app-a's commands cd app-b && just # shows app-b's commands ``` Opt-outs Auto-install is gated to dev invocations. It skips when any of the following are true: JUSTSCALE_NO_COMPLETION_INSTALL=1 — explicit opt-out CI=true — standard CI environment NODE_ENV is production, test, or anything other than development In practice: your dev machine installs once. CI, production hosts, and test runs don't touch your rc file. Middleware and Guards CLI routes support middleware and guards just like HTTP routes: delete-user-command.tsTypeScript ```typescript import { createMiddleware } from '@justscale/core'; import { Cli } from '@justscale/core/cli'; import { z } from 'zod'; import { AuthService } from './auth-service'; const requireAuth = createMiddleware({ inject: { auth: AuthService }, handler: ({ auth }) => async (ctx: { args: { token?: string } }) => { const token = ctx.args.token; if (!token || !auth.verify(token)) { throw new Error('Authentication required'); } return { user: await auth.getUser(token) }; }, }); Cli('delete-user') .input(z.object({ userId: z.string(), token: z.string(), })) .use(requireAuth) .handle(({ userId, user, io }) => { // Only runs if authenticated io.log(`Deleting user ${userId} (authorized by ${user.email})`); }); ``` Calling Other Commands Use CliService to invoke other commands from within a CLI handler: shell-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Cli, CliService } from '@justscale/core/cli'; const ShellController = createController({ inject: { cli: CliService }, routes: (services) => ({ shell: Cli('shell').handle(async ({ io }) => { const { cli } = services; // List all available commands const commands = cli.listCommands(); io.table(commands.map(cmd => ({ command: cmd }))); // Execute another command const result = await cli.execute( 'db migrate', { direction: 'up', steps: 1 }, io ); }), }), }); ``` Next Steps Cluster Request Handling Controllers --- # Cluster Overview URL: https://justscale.sh/docs/cluster/overview Cluster Overview The Unix socket that ties your processes together ℹ️Info This page documents the underlying app.serve() / JustScale().build() API. Most apps don't call it directly. The canonical entrypoint is defineApp(import.meta, env => JustScale().add(env)...) exported from src/app.ts; the just CLI handles env selection, build, compile, and serve. See Quick Start for the typical project shape (no main.ts, no manual app.serve()). The lower-level API below is for embedding, tests, and tooling that needs to drive the runtime by hand. What is the Cluster? When the app starts, JustScale opens a Unix domain socket alongside any HTTP / WebSocket / gRPC transports you wired up. That socket is the cluster — a private, same-user-only RPC channel that lets external processes drive the running app: the just CLI, sibling worker processes, signal delivery between durable processes, scheduled tasks, and HMR reload coordination. The same controllers serve all of it. A route defined with Cli('migrate') is callable from the terminal via just migrate; a route defined with Get('/users') is callable over HTTP. Define once, expose across whichever transports your app loaded. Creating a Cluster (low-level) Under the hood, defineApp calls JustScale().add(...).build() and then app.serve(...). Tests and embeddings can do this by hand: embed.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { UserController } from './controllers/user'; import { DbController } from './controllers/db'; const app = JustScale() .add(UserController) .add(DbController) .build(); // Start serving — HTTP + cluster socket for CLI await app.serve({ http: 3000 }); ``` This single call: Starts an HTTP server on port 3000 (when @justscale/http is loaded) Creates a Unix socket for CLI communication Registers all transport handlers automatically Multi-Transport Controllers Controllers can define routes for multiple transports. Each transport has its own route factory: controllers/user.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, Post } from '@justscale/http'; import { Cli } from '@justscale/core/cli'; import { body } from '@justscale/http/builder'; import { z } from 'zod'; import { UserService } from '../services/user'; const CreateUserArgs = z.object({ email: z.string().email(), password: z.string().min(8), }); export const UserController = createController('user', { inject: { users: UserService }, routes: (services) => ({ // HTTP route - accessible via REST API create: Post('/') .body(CreateUserArgs) .handle(async ({ body, res }) => { const user = await services.users.create(body); res.json({ user }); }), // CLI route - accessible via CLI commands createCli: Cli('create') .input(CreateUserArgs) .handle(async ({ args, io }) => { const user = await services.users.create(args); io.log(`Created user: ${user.email}`); io.result({ email: user.email }); }), // HTTP route for listing list: Get('/') .handle(async ({ res }) => { const allUsers = await services.users.findAll(); res.json({ users: allUsers }); }), // CLI route for listing listCli: Cli('list') .handle(async ({ io }) => { const allUsers = await services.users.findAll(); io.table(allUsers, ['email', 'name']); }), }), }); ``` This controller exposes both HTTP and CLI interfaces: Bash ```bash # HTTP usage: curl -X POST http://localhost:3000/user \ -H "Content-Type: application/json" \ -d '{"email":"test@example.com","password":"secret123"}' curl http://localhost:3000/user # CLI usage: just user create --email test@example.com --password secret123 just user list ``` App Methods app.serve() Start serving all transports. This is the primary way to start your application: serve-options.tsTypeScript ```typescript import JustScale from '@justscale/core'; const app = JustScale().build(); // HTTP + cluster socket await app.serve({ http: 3000 }); // Custom socket path await app.serve({ http: 3000, socketPath: '/tmp/my-app.sock', }); // Socket only (no HTTP) await app.serve({ noSocket: false }); // HTTP only (no socket — CLI won't work) await app.serve({ http: 3000, noSocket: true, }); ``` app.stop() Stop the app and clean up all resources: stop-app.tsTypeScript ```typescript await app.stop(); ``` Direct invocation (tests) A built app exposes its container and routes for in-process testing or direct invocation: testing-direct.tsTypeScript ```typescript import { invoke } from '@justscale/core/cli'; // Direct CLI command invocation — no socket, no HTTP const result = await invoke(app, 'user create', { email: 'test@example.com', password: 'secret123', }); // In-process HTTP testing (no listen() required) import { createTestClient } from '@justscale/testing'; import { httpTransport } from '@justscale/http/testing'; const client = await createTestClient(app, { transports: { http: httpTransport }, }); const typed = client.http.useControllers({ users: UserController }); ``` Features with JustScale Features plug into the same .add(...) chain and can contribute routes to multiple transports: app-with-features.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { AuthFeature } from '@justscale/auth'; import { UserController } from './controllers/user'; const app = JustScale() .add(AuthFeature()) .add(UserController) .build(); await app.serve({ http: 3000 }); ``` The auth feature now provides both HTTP and CLI routes: Bash ```bash # HTTP routes: curl -X POST http://localhost:3000/auth/register curl http://localhost:3000/auth/me?token=xxx # CLI routes: just auth create-user --email admin@example.com just auth list-users just auth delete-session --session abc123 ``` Transport Plugins Transports are registered as plugins. When you import a transport package, it automatically registers itself with the cluster system: transport-registration.tsTypeScript ```typescript // These imports auto-register their transports: import '@justscale/http'; // registers HTTP transport import '@justscale/core/cli'; // registers CLI transport import JustScale from '@justscale/core'; import { UserController } from './controllers/user'; // The app knows about all registered transports const app = JustScale() .add(UserController) .build(); // app.serve() starts all registered transports await app.serve({ http: 3000 }); ``` ℹ️Info Future transports (WebSockets, gRPC, etc.) will follow the same pattern - just import the package and define routes using the transport's route factory. Cluster Socket When app.serve() is called, a Unix domain socket is created for inter-process communication. This socket: Enables the justscale CLI to communicate with your running app Supports bidirectional streaming for progress indicators and prompts Uses local credentials for security (only accessible by the same user) Auto-cleans up on process exit socket-info.tsTypeScript ```typescript await app.serve({ http: 3000 }); console.log(`Socket path: ${app.socketPath}`); // Socket path: /tmp/justscale-1000.sock console.log(`Is serving: ${app.isServing}`); // Is serving: true ``` Connecting to a Running Cluster You can connect to a running cluster from another process: connect-to-cluster.tsTypeScript ```typescript import { connectToCluster } from '@justscale/core/cluster'; // Connect to the running cluster via Unix socket const client = await connectToCluster(); // Invoke a CLI command const result = await client.invoke('user create', { email: 'test@example.com', password: 'secret123', }); // Stream output client.on('stdout', (data) => console.log(data)); client.on('stderr', (data) => console.error(data)); await client.close(); ``` Testing a built app For testing, drive the built app directly — no listen() needed: user.test.tsTypeScript ```typescript import { describe, test } from 'node:test'; import assert from 'node:assert'; import JustScale from '@justscale/core'; import { invoke } from '@justscale/core/cli'; import { createTestClient } from '@justscale/testing'; import { httpTransport } from '@justscale/http/testing'; import { UserController } from './controllers/user'; describe('UserController', () => { const app = JustScale() .add(UserController) .build(); test('creates user via CLI', async () => { const result = await invoke(app, 'user create', { email: 'test@example.com', password: 'secret123', }); assert.ok(typeof (result as any).id === 'string'); }); test('creates user via HTTP', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, }); const { api } = client.http.useControllers({ users: UserController }); const response = await api.users.create({ email: 'test@example.com', password: 'secret123', }); assert.strictEqual(response.status, 200); }); }); ``` Next Steps CLI Usage Request Handling Controllers --- # Models as Services URL: https://justscale.sh/docs/concepts/models-as-services Models as Services Model instances with injected dependencies via prototype chain In most frameworks, models are anemic data containers. Business logic lives in separate service classes that receive model data as arguments. This splits behavior from the data it operates on — the "anemic domain model" anti-pattern. In JustScale, a model instance is not just data. Its prototype is a resolved service. Methods, injected dependencies, and field data all live on the same object. The Prototype Chain When the framework boots, it resolves all injected dependencies and creates a "model service" — a singleton that becomes the prototype of every instance of that model: text ```text instance (own props: field data — email, name, balance) → modelService (injected deps — payments, notifications) → ModelClass.prototype (methods — transfer, validate) → BaseModel.prototype ``` This means when you access this.payments on a model instance, JavaScript walks the prototype chain to the resolved service. All instances share the same dependencies. Fields are own properties. Methods come from the class. Rich Domain Models With behavior colocated with data, your models become expressive domain objects rather than property bags: TypeScript ```typescript class Campaign extends defineModel({ fields: { creator: field.ref(Creator), title: field.string().max(255), goalAmount: field.decimal(12, 2), currentAmount: field.decimal(12, 2).default('0.00'), status: field.enum('CampaignStatus', [ 'draft', 'active', 'funded', 'failed', 'completed', ]), }, }) { get progress() { return Number(this.currentAmount) / Number(this.goalAmount); } get isFundable() { return this.status === 'active' && this.progress < 1; } } ``` The model owns its domain logic. campaign.progress and campaign.isFundable live where they belong — on the entity itself, not in a separate CampaignService that takes a campaign object as an argument. Dependency Injection on Models For advanced cases, models can inject services. This lets model methods access framework capabilities without the caller needing to provide them: TypeScript ```typescript class Order extends defineModel({ fields: { amount: field.decimal(10, 2), status: field.enum('Status', ['pending', 'paid']), }, inject: { payments: PaymentService }, }) { async loadItems(this: Persistent) { // this.payments comes from the prototype chain — not a field return this.payments.getItemsFor(this); } } ``` 💡Tip Injected dependencies are non-enumerable. They don't show up in JSON.stringify, Object.keys, or serialization. They're invisible infrastructure — methods can use them, but they never leak into your data. The Alternative: Anemic Models Without models-as-services, you end up with the classic split: TypeScript ```typescript // Anemic model — just data interface Order { amount: number; status: string; } // Separate service — behavior lives elsewhere class OrderService { constructor(private payments: PaymentService) {} async loadItems(order: Order) { return this.payments.getItemsFor(order); } validate(order: Order) { return order.amount > 0; } } ``` This works, but it scatters domain logic across files. The model doesn't know what it can do. The service doesn't own the data it operates on. In JustScale, the model IS the service — behavior and data are one object. Next Steps References & ID-Free Domain Type States Services --- # References URL: https://justscale.sh/docs/concepts/references References ID-free domain modeling with type-safe references In every other framework, entities reference each other via string or numeric IDs. These IDs are infrastructure details that leak into your domain code, cause type confusion, and create implicit coupling to your storage layer. JustScale replaces IDs with type-safe references that the compiler can verify. The Problem with IDs TypeScript ```typescript // The problem: string IDs everywhere const user = await userRepo.findById('abc123'); const order = await orderRepo.findById(user.orderId); user.id; // string order.userId; // string user.orderId; // string — is this a User ID or Order ID? The type system can't tell ``` String IDs are all the same type. You can accidentally pass a user ID where an order ID is expected, and TypeScript won't catch it. The type system has no idea what kind of entity 'abc123' refers to. References in JustScale In JustScale, entities reference each other with field.ref() — a type-safe, model-scoped reference: TypeScript ```typescript class Campaign extends defineModel({ fields: { creator: field.ref(Creator), // Reference, not a string title: field.string().max(255), goalAmount: field.decimal(12, 2), }, }) {} const campaign = await campaigns.findOne(Campaign.fields.title.eq('My Project')); const creator = await campaign.creator; // Reference is PromiseLike — just await it ``` Reference can only point at a Creator. You cannot pass it where a Reference is expected. The compiler catches the mismatch. Ref — The Unified Reference Type Services don't need to know whether they're receiving a fresh reference, a loaded entity, or a locked entity. Ref unifies all three: TypeScript ```typescript type Ref = Reference | Persistent | Lock>; // Service accepts Ref — works with any of these async transfer(from: Ref, to: Ref, amount: number) { // Framework resolves the ref to a Persistent internally } // All of these work: await transfer(fromAccount, toAccount, 100); // pass entities directly await transfer(Account.ref`${id}`, toAccount, 100); // or a typed reference ``` 💡Tip A persistent entity IS a valid reference. You don't need to extract an ID and wrap it — just pass the entity itself. Boundary Conversion Raw strings only enter the system at boundaries — controllers, process paths, external API calls. The preferred conversion uses .types({ Model }) on routes and processes so the param is already a Ref inside the handler: TypeScript ```typescript // In a controller — param name matches the model key (lowercased), // so .types({ User }) gives you params.user as Ref directly Get('/:user') .types({ User }) .handle(({ params }) => userService.getProfile(params.user)); // In a process — same pattern createProcess({ path: '/campaign/:campaign/lifecycle', types: { Campaign }, async handler({ campaigns }, { campaign }) { // campaign is Ref const found = await campaigns.get(campaign); }, }); ``` When you do get a raw string from an external boundary (a webhook payload, a CLI arg, etc.), convert it with the callable form or a tagged template: TypeScript ```typescript const userRef = User.ref(someId); // call form const userRef2 = User.ref`${someId}`; // tagged template form (same result) ``` Domain code never sees strings. Boundary code converts once, at the edge. The Escape Hatch Sometimes infrastructure truly needs a raw identifier — for URLs, external APIs, or logging. The escape hatch is deliberately awkward: TypeScript ```typescript // Deliberately verbose — signals you're leaving the domain const rawId = Model.ref(entity).identifier; ``` The awkwardness is a feature. If you're reaching for .identifier, you should be asking whether you really need the raw value, or whether you can keep working with typed references. Nominal Identity Preserved Reference and Persistent preserve the real class — not just its fields. When you extend defineModel and add body getters or methods, those members are visible on awaited references. Model.ref uses polymorphic this, so subclasses keep their own identity throughout the type chain. TypeScript ```typescript class Customer extends defineModel({ fields: { firstName: field.string(), lastName: field.string(), }, }) { get fullName() { return `${this.firstName} ${this.lastName}`; } } class Post extends defineModel({ fields: { author: field.ref(Customer), title: field.string(), }, }) {} // post.author is Reference, not Reference const author = await post.author; // Persistent author.fullName; // ✓ typed, the getter is visible ``` In other words: your domain methods travel with the reference. No loss of type information when a model is pulled out through a field.ref edge. 💡Tip At HTTP or CLI boundaries, validate incoming refs with z.ref(Model) — exported from @justscale/core/models. It produces a Zod schema that decodes a raw identifier into a Reference: TypeScript ```typescript import { z } from 'zod'; import { field } from '@justscale/core/models'; const CreatePostBody = z.object({ title: z.string(), author: z.ref(Author).optional(), }); ``` The callable User.ref(someId) / User.ref\`$${someId}\` form is unchanged — still the way to convert a raw string into a typed reference inside handler code. What went away is passing User.ref itself as a Zod schema; use z.ref(User) at route boundaries instead. Next Steps References Models Overview Type States --- # Type States URL: https://justscale.sh/docs/concepts/type-states Type States How data shape controls what your code can do In most frameworks, a model instance is a mutable bag of properties. You can change anything, anytime, and hope the ORM figures out what happened. JustScale takes a different approach: the shape of your data tells you what you can do with it. The Three Forms Every entity in JustScale exists in one of three forms, each with different capabilities: Transient — Unsaved, Writable A newly created entity that hasn't been persisted yet. Nobody else can see it, so it's safe to mutate freely. TypeScript ```typescript const order = new Order({ amount: 100, status: 'pending' }); order.amount = 200; // Fine — it's transient, nobody else has it ``` Persistent — Stored, Readonly An entity loaded from storage. Other processes might be reading it too, so all fields are readonly. You can read, query, and pass it around — but you cannot mutate it. TypeScript ```typescript const order = await orders.findOne(Order.fields.status.eq('pending')); order.amount = 200; // Type error! Persistent fields are readonly order.amount; // Fine — reading is always safe ``` Lock> — Stored, Locked, Writable A persistent entity with an exclusive lock. You've told the framework "I need to mutate this, and nobody else should touch it while I do." The lock removes readonly — mutation is safe again. TypeScript ```typescript using locked = await lockService.acquire(order); locked.amount = 200; // Fine — you hold the lock // Lock releases automatically when 'using' scope ends ``` 💡Tip The using keyword (TC39 Explicit Resource Management) ensures the lock is always released — even if an error is thrown. No try/finally needed. Methods Declare Their Requirements The real power comes when model methods declare what form of data they need via TypeScript's this parameter. The type signature IS the contract: TypeScript ```typescript class Order extends defineModel({ fields: { amount: field.decimal(10, 2), status: field.enum('Status', ['pending', 'paid', 'shipped']), }, inject: { payments: PaymentService }, }) { // Requires a lock — this method mutates async markPaid(this: Lock>) { this.status = 'paid'; } // Works on new orders OR locked ones — both are writable applyDiscount(this: Transient | Lock>, pct: number) { this.amount *= (1 - pct / 100); } // Just needs a stored order — read-only access async loadItems(this: Persistent) { return this.payments.getItemsFor(this); } // Works on anything — no state requirement validate() { return this.amount > 0; } } ``` Each method is honest about its needs. The caller knows exactly what to provide: TypeScript ```typescript const order = await orders.findOne(Order.fields.status.eq('pending')); order.validate(); // Works — no state requirement order.loadItems(); // Works — order is Persistent order.markPaid(); // Type error! Need Lock>, got Persistent using locked = await lockService.acquire(order); locked.markPaid(); // Works — locked is Lock> locked.validate(); // Works — still satisfies "anything" ``` Why This Matters In traditional frameworks, mutation bugs are runtime surprises: Mutating an entity that isn't saved yet — then wondering where the data went Mutating without a lock — then hitting a race condition in production Calling a method that assumes the entity is persisted — on a transient instance With type states, these bugs become compile errors. If your code compiles, every method received data in the form it expected. No hidden locking requirements, no surprise side effects. What won't compile Each of the guarantees above is pinned by a fixture under docs/examples/concepts/type-safety/. The // @ts-expect-error directives are run through tsc --noEmit on every build — if one of the expected errors stops firing, the build breaks. So the claims here can't silently drift from what the compiler actually enforces. 01-ref-not-id.ts — Refis not a string; one model's ref cannot be passed where another's is expected. 02-locked-mutations.ts — update / save(existing) / delete require Locked; Ref and Persistentdon't satisfy it. 03-di-deps.ts — JustScale().add(X) rejects components whose inject:deps aren't registered; the error names the missing token. 04-route-types.ts — .types({ Model })transforms path params into Ref; the values must be model classes, not strings. 05-signal-payloads.ts — defineSignals emit calls check every field: missing fields, wrong types, extras, and Ref-instead-of-Locked all fail to compile. 06-builder-chain.ts — removing a .add(bindRepository(...)) from the builder chain makes the DOWNSTREAM .add(AuthFeature) fail, so the error points at the consumer waiting on the missing dep. Side-by-side: delete one line, see which .add() breaks The builder tracks what each step provides and refuses the next .add(X) when Xdeclares a dep that hasn't been supplied. Here's the same chain with and without the User-repository binding: TypeScript ```typescript // ✓ Working — AuthFeature's deps are all satisfied before it's added const app = JustScale() .add(InMemoryLockFeature) .add(InMemoryProcessFeature) .add(bindRepository(ModelRepository.of(User), new InMemoryRepository())) .add(bindRepository(ModelRepository.of(Session), new InMemoryRepository())) .add(bindService(AbstractEmailSender, ConsoleEmailSender)) .add(AuthFeature) .build(); ``` TypeScript ```typescript // ✗ Broken — the User repository binding is missing. // TypeScript flags the .add(AuthFeature) line, NOT the deleted one. const app = JustScale() .add(InMemoryLockFeature) .add(InMemoryProcessFeature) // .add(bindRepository(ModelRepository.of(User), new InMemoryRepository())) ← deleted .add(bindRepository(ModelRepository.of(Session), new InMemoryRepository())) .add(bindService(AbstractEmailSender, ConsoleEmailSender)) .add(AuthFeature) // ^^^^^^^^^^^ // Argument of type 'FeatureToken<[...]>' is not assignable to // parameter of type 'MissingDepsError<..., ModelRepositoryToken>'. .build(); ``` The value isn't "something broke, find it" — the error names the consumer (AuthFeature) and the missing provider (ModelRepositoryToken). That's enough to know what to add and where. Next Steps Models as Services Locks Models Overview Frequently asked questions What are the type states in JustScale? Three: Transient is unsaved and writable, Persistent is stored and read-only, and Locked is stored, locked, and safe to mutate. Why is a Persistent read-only? Because other code may be reading it concurrently. To change stored data you must hold a Locked, which you obtain with repo.lock(ref) - an acquire that is atomic with the read. How do I mutate a stored entity? Acquire a lock with `using locked = await repo.lock(ref)`, then pass that Locked to repo.update, save, or delete. The lock is your concurrency control, so stale writes are structurally impossible. --- # Configuration URL: https://justscale.sh/docs/configuration/overview Configuration Type-safe configuration management with Zod validation The @justscale/core/config package provides type-safe configuration management with Zod schema validation, environment variable support, profile management, and runtime mutations. Installation Bash ```bash pnpm add @justscale/core/config ``` Defining Config Partials Use defineConfigPartial to create type-safe configuration sections with Zod schemas: config/database.tsTypeScript ```typescript import { defineConfigPartial } from '@justscale/core/config'; import { z } from 'zod'; // Define a config partial with a Zod schema export const DatabaseConfig = defineConfigPartial('database', z.object({ host: z.string().default('localhost'), port: z.number().default(5432), database: z.string(), username: z.string(), password: z.string(), poolSize: z.number().min(1).max(100).default(10), })); // Type is inferred from the schema type DatabaseConfigType = z.infer; // { host: string; port: number; database: string; ... } ``` Injecting Configuration Use Config.of() to create injection tokens for config partials: services/database.service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import { Config } from '@justscale/core/config'; import { DatabaseConfig } from '../config/database'; export class DatabaseService extends defineService({ inject: { // Config.of() creates a type-safe injection token config: Config.of(DatabaseConfig), }, factory: ({ config }) => { // config is fully typed based on the Zod schema const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`; return { getConnectionString: () => connectionString, getPoolSize: () => config.poolSize, }; }, }) {} ``` Providing Configuration Use createConfig to provide config values at application startup: app.tsTypeScript ```typescript import JustScale, { defineApp } from '@justscale/core'; import type { AppEnv } from './env-contract'; import { AppConfig } from './config'; import { DatabaseService } from './services/database.service'; // defineApp is the canonical bootstrap. It handles env loading, // build/compile, and CLI-vs-serve dispatch. No app.serve() call, // no main.ts — `just dev` runs this when the active env is dev. export default defineApp(import.meta, (env: AppEnv) => JustScale() .add(env) .add(AppConfig) // Provide configuration .add(DatabaseService) // Services can now inject config ); ``` Environment Variables The EnvServiceDef provides type-safe access to environment variables: services/example.service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import { EnvServiceDef } from '@justscale/core/config'; export class ExampleService extends defineService({ inject: { env: EnvServiceDef, }, factory: ({ env }) => ({ getApiKey: () => env.get('API_KEY'), getOptionalValue: () => env.get('OPTIONAL_VAR', 'default-value'), isProduction: () => env.get('NODE_ENV') === 'production', }), }) {} ``` Profile-Based Configuration Use profiles to manage different configuration sets for development, staging, and production environments: config/profiles.tsTypeScript ```typescript import { createConfig } from '@justscale/core/config'; import { DatabaseConfig } from './database'; // Development profile export const DevConfig = createConfig({ factory: () => ({ [DatabaseConfig.key]: { host: 'localhost', port: 5432, database: 'myapp_dev', username: 'dev', password: 'dev', poolSize: 5, }, }), }); // Production profile export const ProdConfig = createConfig({ factory: () => ({ [DatabaseConfig.key]: { host: process.env.DB_HOST!, port: parseInt(process.env.DB_PORT!), database: process.env.DB_NAME!, username: process.env.DB_USER!, password: process.env.DB_PASS!, poolSize: 50, }, }), }); ``` Runtime Mutations The ConfigServiceDef allows runtime configuration updates and watching for changes: services/settings.service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import { ConfigServiceDef, Config } from '@justscale/core/config'; import { AppSettings } from '../config/settings'; export class SettingsService extends defineService({ inject: { configService: ConfigServiceDef, settings: Config.of(AppSettings), }, factory: ({ configService, settings }) => ({ // Get current value getDebugMode: () => settings.debugMode, // Update config at runtime setDebugMode: async (enabled: boolean) => { configService.set(AppSettings, 'debugMode', enabled); }, // Watch for config changes watchSettings: async function* () { for await (const [oldConfig, newConfig] of configService.watch(AppSettings)) { yield { old: oldConfig, new: newConfig }; } }, }), }) {} ``` Runtime mutations are persisted to .justscale/config.json and survive application restarts. CLI Integration Add configuration CLI commands to manage settings from the command line: terminal.shTypeScript ```typescript # View all config justscale config list # Get a specific value justscale config get database.host # Set a value at runtime justscale config set database.poolSize 25 # View config as JSON justscale config dump ``` The poolSize above is illustrative. The real Postgres connection pool is set via PostgresClientConfig — e.g. just config set postgres:client max 25. Profile Service The ProfileServiceDef provides file-based profile management for switching between configuration sets (local, dev, staging, production): services/deploy.service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import { ProfileServiceDef } from '@justscale/core/config'; export class DeployService extends defineService({ inject: { profiles: ProfileServiceDef, }, factory: ({ profiles }) => ({ // Get active profile (checks JUSTSCALE_PROFILE env, then .justscale/.active-profile) getCurrentProfile: () => profiles.active(), // Switch to a different profile switchProfile: (name: string) => { profiles.use(name); // Throws if profile doesn't exist }, // List all available profiles listProfiles: () => profiles.list(), // Create a new profile (optionally copy from existing) createProfile: (name: string, copyFrom?: string) => { profiles.create(name, copyFrom); }, // Compare two profiles diffProfiles: (from: string, to: string) => { return profiles.diff(from, to); // Returns: [{ key: 'database.host', from: 'localhost', to: 'prod-db' }, ...] }, }), }) {} ``` Profile Priority The active profile is determined by (in order): JUSTSCALE_PROFILE environment variable .justscale/.active-profile file Default: local Profile CLI Commands terminal.shTypeScript ```typescript # List available profiles justscale profile list # Switch to a profile justscale profile use staging # Create a new profile (copy from existing) justscale profile create prod --from staging # Compare profiles justscale profile diff staging prod ``` Validation All configuration is validated against the Zod schema at startup and during runtime mutations: config/api.tsTypeScript ```typescript import { defineConfigPartial } from '@justscale/core/config'; import { z } from 'zod'; export const ApiConfig = defineConfigPartial('api', z.object({ // Required fields baseUrl: z.string().url(), apiKey: z.string().min(32), // Optional with defaults timeout: z.number().positive().default(30000), retries: z.number().int().min(0).max(10).default(3), // Enum values logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'), })); ``` If validation fails, an error is thrown with detailed information about which fields failed and why. Best Practices Group related settings - Create separate config partials for each concern (database, redis, api, etc.) Use sensible defaults - Provide default values in your Zod schemas for development convenience Validate early - Configuration is validated at startup, failing fast if values are invalid Keep secrets out of code - Use environment variables for sensitive values like passwords and API keys Use profiles - Create separate config components for different environments to avoid conditional logic Next Steps Services Cluster CLI Usage --- # Authentication URL: https://justscale.sh/docs/features/auth Authentication User authentication with database sessions The @justscale/auth package provides a complete authentication system with user registration, password hashing, session management, and route protection. Installation Bash ```bash pnpm add @justscale/auth ``` Quick Start The AuthFeature bundles all auth services together. Just provide repositories for User and Session models: main.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { AuthFeature, User, Session } from '@justscale/auth'; import { createPgModel, createPgRepository, PostgresClient } from '@justscale/postgres'; // Create PostgreSQL models const PgUser = createPgModel(User, { table: 'users' }); const PgSession = createPgModel(Session, { table: 'sessions' }); // Build the app const app = JustScale() .add(PostgresClient) .add(createPgRepository(PgUser)) .add(createPgRepository(PgSession)) .add(AuthFeature) // Provides UserService, SessionService, PasswordService .add(AuthController) .build(); await app.serve({ http: 3000 }); ``` Built-in Models The auth package provides pre-defined User and Session models: User Model models/user.tsTypeScript ```typescript // Built-in User model fields: { email: string, // Unique email address passwordHash: string, // Scrypt-hashed password name?: string, // Optional display name emailVerifiedAt?: Date, // When email was verified lastLoginAt?: Date, // Last successful login } ``` Session Model models/session.tsTypeScript ```typescript // Built-in Session model fields: { user: Reference, // Reference to the user token: string, // Random 64-char hex token userAgent?: string, // Browser/client info ipAddress?: string, // Client IP address expiresAt: Date, // Session expiration lastActiveAt: Date, // Last activity timestamp } ``` Services UserService Handles user registration, authentication, and profile updates: controllers/auth.controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; import { UserService, SessionService, UserExistsError } from '@justscale/auth'; export const AuthController = createController('/auth', { inject: { users: UserService, sessions: SessionService, }, routes: ({ users, sessions }) => ({ register: Post('/register') .handle(async ({ body, res }) => { try { const user = await users.register(body.email, body.password, body.name); const session = await sessions.create(user); res.json({ token: session.token, user: { email: user.email } }); } catch (e) { if (e instanceof UserExistsError) { res.status(409).json({ error: 'Email already registered' }); } else throw e; } }), login: Post('/login') .handle(async ({ body, res }) => { const user = await users.authenticate(body.email, body.password); if (!user) { res.status(401).json({ error: 'Invalid credentials' }); return; } const session = await sessions.create(user); res.json({ token: session.token }); }), }), }); ``` UserService Methods user-service-api.tsTypeScript ```typescript // Registration const user = await users.register(email, password, name?); // Authentication (returns user or undefined) const user = await users.authenticate(email, password); // Lookup const user = await users.get(User.ref`${id}`); const user = await users.findByEmail(email); // Updates await users.updatePassword(user, newPassword); await users.verifyEmail(userId); // Sets emailVerifiedAt ``` SessionService Manages session tokens with automatic expiration: session-service-api.tsTypeScript ```typescript // Create session (default TTL: 7 days) const session = await sessions.create(user, { userAgent: req.headers['user-agent'], ipAddress: req.ip, ttlMs: 30 * 24 * 60 * 60 * 1000, // 30 days }); // Lookup (returns null if expired) const session = await sessions.findByToken(token); // Update activity timestamp await sessions.touch(sessionId); // Logout await sessions.revoke(sessionId); await sessions.revokeAllForUser(userRef); // Logout everywhere // Cleanup await sessions.revokeExpired(); // Remove expired sessions ``` PasswordService Secure password hashing using scrypt with timing-safe comparison: password-service-api.tsTypeScript ```typescript // Hash a password (scrypt with random salt) const hash = await passwords.hash('user-password'); // Returns: "salt:derivedKey" (hex encoded) // Verify password (timing-safe) const valid = await passwords.verify('user-password', hash); ``` Middleware Protect routes with authentication middleware: Required Authentication protected-routes.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { auth } from '@justscale/auth'; export const ProfileController = createController('/profile', { inject: {}, routes: () => ({ // Requires valid Authorization header me: Get('/') .use(auth) // Adds session + user to context .handle(({ user, res }) => { res.json({ email: user.email, name: user.name, }); }), }), }); ``` The auth middleware extracts the Bearer token from the Authorization header, validates it, and adds session and user to the context. Throws AuthenticationError if invalid. Optional Authentication optional-auth.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { optionalAuth } from '@justscale/auth'; // Works with or without authentication feed: Get('/feed') .use(optionalAuth) // session/user may be null .handle(({ user, res }) => { if (user) { // Personalized feed for logged-in users res.json({ feed: getPersonalizedFeed(user) }); } else { // Generic feed for anonymous users res.json({ feed: getPublicFeed() }); } }), ``` Guards Add authorization checks after authentication: Files guards-example.tsticket.model.tsprincipals.ts guards-example.tsticket.model.tsprincipals.ts guards-example.tsTypeScript ```typescript import { Get, Patch, Post } from '@justscale/http'; import { auth, requireAuth, requireVerifiedEmail } from '@justscale/auth'; import { Ticket } from '../models/ticket'; // Require authenticated user adminPanel: Get('/admin') .use(auth) .guard(requireAuth) .handle(...), // Require verified email sendMessage: Post('/messages') .use(auth) .guard(requireVerifiedEmail) .handle(...), // Require ownership via a MODEL permission — Ticket.can.close is declared // on the Ticket model as permit(Customer).when(customer). Under the hood // that rule is queryable: toCondition(principal) yields an ORM condition // the repository can push into a WHERE clause (ticket.customer_id = :id). // The same rule runs at guard time against the resolved :ticket param. closeTicket: Post('/tickets/:ticket/close') .types({ Ticket }) .use(auth) .guard(Ticket.can.close) .handle(({ params, res }) => { // Guard passed — the authenticated Customer owns params.ticket res.status(204).end(); }), ``` Error Handling The auth package exports specific error classes for handling auth failures: error-handling.tsTypeScript ```typescript import { AuthenticationError, UserExistsError, InvalidCredentialsError, } from '@justscale/auth'; // In your error handler app.use(async (ctx, next) => { try { await next(); } catch (e) { if (e instanceof AuthenticationError) { ctx.res.status(401).json({ error: e.message }); } else if (e instanceof UserExistsError) { ctx.res.status(409).json({ error: 'Email already registered' }); } else if (e instanceof InvalidCredentialsError) { ctx.res.status(401).json({ error: 'Invalid email or password' }); } else throw e; } }); ``` Database Schema Create the required tables for User and Session models: migrations/001_auth.sqlTypeScript ```typescript -- Users table CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, name VARCHAR(100), email_verified_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_users_email ON users(email); -- Sessions table CREATE TABLE sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(255) NOT NULL UNIQUE, user_agent VARCHAR(500), ip_address VARCHAR(45), expires_at TIMESTAMPTZ NOT NULL, last_active_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_sessions_token ON sessions(token); CREATE INDEX idx_sessions_user_id ON sessions(user_id); CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); ``` Complete Example src/main.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { listen } from '@justscale/http'; import { AuthFeature, User, Session } from '@justscale/auth'; import { createPgModel, createPgRepository, PostgresClient } from '@justscale/postgres'; import { AuthController } from './controllers/auth'; import { ProfileController } from './controllers/profile'; const PgUser = createPgModel(User, { table: 'users' }); const PgSession = createPgModel(Session, { table: 'sessions' }); const app = JustScale() .add(PostgresClient) .add(createPgRepository(PgUser)) .add(createPgRepository(PgSession)) .add(AuthFeature) .add(AuthController) .add(ProfileController) .build(); await app.serve({ http: 3000 }); ``` Best Practices Use HTTPS in production - Tokens are sent in headers, so always use TLS to protect them in transit Set appropriate session TTL - Balance security (shorter) vs UX (longer) based on your app's needs Clean up expired sessions - Run sessions.revokeExpired()periodically to remove stale sessions Store tokens securely on client - Use httpOnly cookies or secure storage, never localStorage for sensitive apps Implement rate limiting - Protect login/register endpoints from brute force attacks Next Steps Middleware Guards PostgreSQL Repositories --- # Datastar Integration URL: https://justscale.sh/docs/features/datastar Datastar Integration Server-Sent Events and reactive signal streaming with Datastar The @justscale/datastar package provides seamless integration with Datastar , enabling SSE (Server-Sent Events) streaming and reactive signal management for real-time updates in your applications. Installation Bash ```bash npm install @justscale/datastar ``` Basic Setup Import the plugin to register the Watch route factory: src/controllers/app.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { Watch } from '@justscale/datastar'; export const AppController = createController('/api', { routes: () => ({ // Regular HTTP route items: Get('/items').handle(({ res }) => { res.json({ items: ['item1', 'item2'] }); }), // SSE streaming route with Datastar updates: Watch('/items/updates', async function* () { // Stream updates to the client for (let i = 0; i < 10; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); yield { count: i }; } }), }), }); ``` What is Datastar? Datastar is a hypermedia-oriented framework that uses Server-Sent Events (SSE) to create reactive, real-time web applications. It allows you to: Stream HTML fragments and state updates to the client Build reactive UIs without heavy JavaScript frameworks Maintain server-side state and push updates in real-time Handle long-lived connections for live data feeds The Watch Route Factory The Watch factory creates SSE endpoints that continuously stream data to connected clients: notifications-controller.tsTypeScript ```typescript import { Watch } from '@justscale/datastar'; const NotificationsController = createController('/notifications', { routes: () => ({ // Generator-based streaming stream: Watch('/', async function* () { while (true) { const notification = await getNextNotification(); yield { notification }; await new Promise(resolve => setTimeout(resolve, 1000)); } }), }), }); ``` Dirty-Tracking with SignalRepository Inside a Watch handler, createSignalRepositorywraps the route's stream with a validated, dirty-aware model. Mutate fields freely — calling save() only emits the signals that actually changed. src/items-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Watch, createSignalRepository } from '@justscale/datastar'; import { z } from 'zod'; const ItemsSchema = z.object({ items: z.array(z.string()).default([]), count: z.number().default(0), }); async function waitForNextItem(): Promise { // Stand-in for whatever produces the next item (a queue, a channel, etc). return new Promise((r) => setTimeout(() => r(`item-${Date.now()}`), 100)); } export const ItemsController = createController('/items', { inject: {}, routes: () => ({ // Each connection gets its own repository bound to that stream. watch: Watch('/watch', async function* ({ stream, signals }) { const repo = createSignalRepository(ItemsSchema, stream); const model = repo.create(signals); while (true) { const next = await waitForNextItem(); model.items = [...model.items, next]; model.count = model.items.length; repo.save(model); // emits only items + count — nothing else changed yield {}; // keep the generator running; repo handled the emit } }), }), }); ``` HTML Streaming Stream HTML fragments for hypermedia-driven updates: dashboard-controller.tsTypeScript ```typescript import { html } from '@justscale/datastar'; import { Watch } from '@justscale/datastar'; export const DashboardController = createController('/dashboard', { routes: () => ({ metrics: Watch('/metrics', async function* () { while (true) { const metrics = await fetchMetrics(); // Stream HTML fragment yield html`

CPU: ${metrics.cpu}%

Memory: ${metrics.memory}%

`; await new Promise(resolve => setTimeout(resolve, 5000)); } }), }), }); ``` ℹ️Info The html template tag escapes variables by default for security. Use rawHtml if you need to render unescaped HTML (use carefully). Stream Context Watch routes receive a special context with streaming utilities: feed.tsTypeScript ```typescript Watch('/feed', async function* ({ stream, req, app }) { // stream: SSE writing utilities // req: HTTP request object // app: Application instance // Plus any injected dependencies for await (const event of getEvents()) { yield { event }; } }) ``` Integration with Services Inject services into Watch routes just like regular routes: Files event-bus-service.tsevents-controller.ts event-bus-service.tsevents-controller.ts event-bus-service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; class EventBusService extends defineService({ inject: {}, factory: () => { const subscribers = new Set<(event: any) => void>(); return { subscribe: (callback: (event: any) => void) => { subscribers.add(callback); return () => subscribers.delete(callback); }, publish: (event: any) => { subscribers.forEach(sub => sub(event)); }, }; }, }) {} ``` Client-Side Usage On the client side, use Datastar's attributes to connect to your Watch endpoints: HTML ```html
0
``` SignalRepository API Create a repository for a stream Takes a Zod schema and the Datastar stream from the Watch context. The returned repository validates inputs and tracks mutations. create.tsTypeScript ```typescript const repo = createSignalRepository( z.object({ count: z.number().default(0) }), stream, ); ``` Create a model from current signals hydrate.tsTypeScript ```typescript // Hydrate from the signals the client sent (or undefined for schema defaults). const model = repo.create(signals); model.count; // typed as number ``` Emit only what changed save.tsTypeScript ```typescript model.count = model.count + 1; const changed = repo.save(model); // true if anything was dirty // → mergeSignals({ count: 2 }) — other fields are untouched on the client. ``` Force-emit every field save-all.tsTypeScript ```typescript repo.saveAll(model); // sends every field, regardless of dirty state ``` Use Cases Live dashboards - Stream real-time metrics and stats Notifications - Push notifications to connected clients Chat applications - Real-time message delivery Live updates - Broadcast database changes to users Progress tracking - Stream long-running job progress Collaborative editing - Sync state across multiple clients Best Practices Handle cleanup - Use try/finally to clean up subscriptions Rate limit updates - Don't stream too frequently Use heartbeats - Send periodic keep-alive messages Handle reconnection - Client should retry on disconnect Validate data - Sanitize HTML content before streaming Next Steps Features Overview Request Handling Testing --- # Event Bus URL: https://justscale.sh/docs/features/event Event Bus Type-safe event-driven architecture with publish/subscribe patterns The @justscale/event package provides a type-safe event bus implementation that enables clean event-driven architecture in your JustScale applications. Define events with Zod schemas and get full type safety for both publishers and subscribers. Installation Bash ```bash npm install @justscale/event ``` Basic Concepts The event bus pattern allows different parts of your application to communicate without tight coupling. Publishers emit events, and subscribers listen for specific event types, all with full TypeScript type safety. Creating an Event Bus Define your event schemas using Zod: src/events.tsTypeScript ```typescript import { createEventBus } from '@justscale/event'; import { z } from '@justscale/core/models'; export const AppEvents = createEventBus({ 'user.created': z.object({ user: z.ref(User) }), 'user.deleted': z.object({ user: z.ref(User) }), 'order.placed': z.object({ order: z.ref(Order) total: z.number(), items: z.array(z.object({ product: z.ref(Product) quantity: z.number(), })), }), 'order.cancelled': z.object({ order: z.ref(Order), reason: z.string(), }), }); ``` Emitting Events Inject the event bus into services or controllers to emit events: src/services/user-service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import { AppEvents } from '../events'; const db = { users: { insert: async (u: any) => ({ id: '1', ...u }), delete: async (id: string) => {} } }; export class UserService extends defineService({ inject: { events: AppEvents }, factory: ({ events }) => ({ async createUser(email: string, password: string) { // Create user in database const user = await db.users.insert({ email, password }); // Emit typed event — subscriber resolves the ref if they need more data await events.emit('user.created', { user }); return user; }, async deleteUser(user: Ref) { await db.users.delete(user); // Type-safe event emission await events.emit('user.deleted', { user }); }, }), }) {} ``` ℹ️Info The emit method is fully type-safe. TypeScript will enforce that you provide the correct payload shape for each event type. Listening to Events Use the .on() route builder to create event handlers in controllers: src/controllers/notification.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { AppEvents } from '../events'; import { MailerService } from '../services/mailer'; export const NotificationController = createController({ inject: { mailer: MailerService }, routes: (services) => ({ // Handle user.created events onUserCreated: AppEvents.on('user.created') .handle(async ({ payload }) => { // payload is typed as { user: Ref } const user = await payload.user.resolve(); services.mailer.sendWelcomeEmail(user.email); }), // Handle user.deleted events onUserDeleted: AppEvents.on('user.deleted') .handle(({ payload }) => { // payload is typed as { user: Ref } console.log('User deleted', payload.user); }), }), }); ``` Wildcard Patterns Listen to multiple related events using wildcard patterns: audit-controller.tsTypeScript ```typescript export const AuditController = createController({ routes: () => ({ // Listen to all user events onAnyUserEvent: AppEvents.on('user.*') .handle(({ eventName, payload }) => { // eventName: 'user.created' | 'user.deleted' // payload: union of all matching event payloads console.log('User event: ' + eventName, payload); // Store in audit log auditLog.record(eventName, payload); }), // Listen to all events onAnyEvent: AppEvents.on('*') .handle(({ eventName, payload }) => { // Receives all events from the bus console.log('Event: ' + eventName); }), // Listen to multiple specific patterns onOrderEvent: AppEvents.on('order.*') .handle(({ eventName, payload }) => { // Handles 'order.placed' and 'order.cancelled' if (eventName === 'order.placed') { // payload is narrowed to order.placed payload } }), }), }); ``` Middleware and Guards Event handlers support middleware and guards just like HTTP routes: event-controller.tsTypeScript ```typescript import { createMiddleware } from '@justscale/core'; const LoggingMiddleware = createMiddleware(({ next }) => { console.log('Event handler starting'); const result = next(); console.log('Event handler completed'); return result; }); export const EventController = createController({ routes: () => ({ onUserCreated: AppEvents.on('user.created') .use(LoggingMiddleware) .handle(({ payload }) => { // Handler logic with logging }), }), }); ``` Model Events For entity-based events, use createModelEvents to automatically generate CRUD event schemas: user-events.tsTypeScript ```typescript import { createModelEvents } from '@justscale/event'; import { User } from './models/user'; export const UserEvents = createModelEvents({ model: User, events: { created: true, updated: true, deleted: true, }, }); // Automatically creates: // - 'User.created' event with { entity: User, id: string } // - 'User.updated' event with { entity: User, id: string, changes: Partial } // - 'User.deleted' event with { id: string } ``` Emit model events from repositories: user-repository.tsTypeScript ```typescript export const UserRepository = createRepository(UserModel, { inject: { events: UserEvents }, factory: ({ events }) => ({ async create(data: UserData) { const saved = await db.users.insert(data); await events.emit('created', { entity: saved }); return saved; }, async update(user: Ref, changes: Partial) { const saved = await db.users.update(user, changes); await events.emit('updated', { entity: saved, changes }); return saved; }, async remove(user: Ref) { await db.users.delete(user); await events.emit('deleted', { ref: user }); }, }), }); ``` Event Subscriptions Use the Subscribe route builder for long-lived subscriptions: subscription-controller.tsTypeScript ```typescript import { Subscribe } from '@justscale/event'; import { AppEvents } from './events'; export const SubscriptionController = createController({ routes: () => ({ // Subscribe returns an async iterator userEvents: Subscribe(AppEvents, 'user.*', async function* ({ eventName, payload }) { // This generator receives all matching events yield { type: eventName, data: payload }; }), }), }); ``` Event Context Event handlers receive a rich context object: event-context.tsTypeScript ```typescript AppEvents.on('user.created').handle(({ eventName, // 'user.created' payload, // { user: Ref } metadata, // Optional metadata passed during emit timestamp, // When the event was emitted // Plus any injected dependencies }) => { // Handler logic }); ``` Emit Options Pass options when emitting events: emit-options.tsTypeScript ```typescript await events.emit('user.created', payload, { // Add custom metadata metadata: { source: 'admin-panel', requestId: req.id, }, // Wait for all handlers to complete await: true, // Timeout for handlers timeout: 5000, }); ``` Error Handling Handle errors in event handlers gracefully: error-handling.tsTypeScript ```typescript AppEvents.on('order.placed').handle(async ({ payload }) => { try { await processOrder(payload.orderId); } catch (error) { console.error('Failed to process order:', error); // Emit compensation event await events.emit('order.failed', { orderId: payload.orderId, error: error.message, }); } }); ``` ⚠️Warning Event handlers run asynchronously by default. If a handler throws, it won't affect other handlers or the emitter unless you use await: true. Testing Mock event buses in tests: event.test.tsTypeScript ```typescript import { createTestSession } from '@justscale/testing'; import { AppEvents } from './events'; test('user creation emits event', async () => { const session = createTestSession({ services: [UserService], }); const events: any[] = []; const mockEvents = { emit: vi.fn(async (name, payload) => { events.push({ name, payload }); }), }; const userService = session.get(UserService, { events: mockEvents, }); await userService.createUser('test@example.com', 'password'); expect(mockEvents.emit).toHaveBeenCalledWith('user.created', { user: expect.anything(), }); }); ``` Use Cases Decoupled notifications - Send emails/SMS without coupling to business logic Audit logging - Track all system events in one place Cache invalidation - Clear caches when data changes Webhooks - Trigger external API calls on events Analytics - Track user actions and system behavior Durable processes - Orchestrate complex workflows Best Practices Keep events focused - One event should represent one thing happening Use past tense - Events represent things that have happened Include context - Add enough data for handlers to act Don't return values - Events are fire-and-forget (unless using await: true) Handle errors - Event handlers should be resilient Avoid cycles - Don't emit events in response to the same event Next Steps Features Overview Services Testing --- # OpenTelemetry URL: https://justscale.sh/docs/features/otel OpenTelemetry Distributed tracing and observability with OpenTelemetry The @justscale/feature-otel package provides automatic distributed tracing for JustScale applications using OpenTelemetry. It creates spans for HTTP requests, records errors, and propagates trace context across services. Installation Bash ```bash npm install @justscale/feature-otel ``` Basic Setup Add the otelFeature to your application to enable automatic tracing: src/app.tsTypeScript ```typescript import JustScale, { defineApp } from '@justscale/core'; import type { AppEnv } from './env-contract'; import { otelFeature } from '@justscale/feature-otel'; import { MyController } from './controllers'; export default defineApp(import.meta, (env: AppEnv) => JustScale() .add(env) .add(otelFeature({ serviceName: 'my-api' })) .add(MyController) ); ``` What Gets Traced Automatic span creation for every HTTP request Error recording and exception tracking Log-to-span-event conversion Distributed tracing via W3C Trace Context headers Production Setup with OTLP For production, configure the OpenTelemetry SDK to export traces to your observability backend: src/app.tsTypeScript ```typescript import JustScale, { defineApp } from '@justscale/core'; import type { AppEnv } from './env-contract'; import { otelFeature } from '@justscale/feature-otel'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; // 1. Initialize the OpenTelemetry SDK at module top-level so it's // running before defineApp's composition starts emitting spans. const sdk = new NodeSDK({ serviceName: 'my-api', traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces', }), }); sdk.start(); // 2. Compose the app export default defineApp(import.meta, (env: AppEnv) => JustScale() .add(env) .add(otelFeature({ serviceName: 'my-api' })) ); ``` This sends traces to any OTLP-compatible backend like Jaeger, Tempo, Honeycomb, or Datadog. Manual Span Creation Create custom spans for specific operations within your services: src/services/order-service.tsTypeScript ```typescript import { withSpan, withSpanSync } from '@justscale/feature-otel'; // Async operations async function processOrder(orderId: string) { return withSpan('process-order', { orderId }, async () => { await validateOrder(orderId); await chargePayment(orderId); await sendConfirmation(orderId); }); } // Sync operations function calculateTax(amount: number) { return withSpanSync('calculate-tax', { amount }, () => { return amount * 0.1; }); } async function validateOrder(orderId: string) { /* ... */ } async function chargePayment(orderId: string) { /* ... */ } async function sendConfirmation(orderId: string) { /* ... */ } ``` Accessing the Active Span Get the current span to add attributes or events: src/services/payment-service.tsTypeScript ```typescript import { getActiveSpan } from '@justscale/feature-otel'; function processPayment(amount: number) { const span = getActiveSpan(); if (span) { span.setAttribute('payment.amount', amount); span.addEvent('payment.started'); } // ... process payment logic span?.addEvent('payment.completed'); } ``` Distributed Tracing Propagate trace context when calling external services: src/services/api-client.tsTypeScript ```typescript import { createTracedFetch, getTraceHeaders } from '@justscale/feature-otel'; // Option 1: Use createTracedFetch for automatic propagation const tracedFetch = createTracedFetch(); const response = await tracedFetch('https://api.example.com/data'); // Option 2: Manual header injection const headers = getTraceHeaders(); const manualResponse = await fetch('https://api.example.com/data', { headers: { ...headers, 'Content-Type': 'application/json', }, }); ``` Configuration Options config-interface.tsTypeScript ```typescript interface OtelFeatureConfig { // Service name for spans (required) serviceName: string; // Whether to record exceptions in spans (default: true) recordExceptions?: boolean; // Whether to record logs as span events (default: true) recordLogs?: boolean; // Attributes to add to all spans defaultAttributes?: Record; } ``` src/app.tsTypeScript ```typescript import { otelFeature } from '@justscale/feature-otel'; const feature = otelFeature({ serviceName: 'my-api', recordExceptions: true, recordLogs: true, defaultAttributes: { 'deployment.environment': 'production', 'service.version': '1.0.0', }, }); ``` Viewing Traces Use an OTLP-compatible backend to view your traces: Jaeger - Open source, self-hosted Grafana Tempo - Scalable trace storage Honeycomb - SaaS observability platform Datadog - Full observability suite Local Development with Jaeger Bash ```bash # Run Jaeger with Docker docker run -d --name jaeger \ -p 16686:16686 \ -p 4318:4318 \ jaegertracing/all-in-one:latest # View traces at http://localhost:16686 ``` Next Steps Features Overview Error Handling Debugging --- # Features URL: https://justscale.sh/docs/features/overview Features Pre-built feature modules for rapid application development Features in JustScale are self-contained modules that bundle services, controllers, and configuration into reusable packages. They provide production-ready functionality that you can add to your application with a single import. What are Features? A feature is a composable unit that encapsulates related functionality. Features can depend on other features, and JustScale automatically resolves and initializes them in the correct order. src/app.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { ChannelFeature, MemoryChannelBackend } from '@justscale/core'; import { ShellFeature } from '@justscale/feature-shell'; const app = JustScale() .add(ShellFeature) .add(ChannelFeature) .add(MemoryChannelBackend) .build(); await app.serve({ http: 3000 }); ``` Feature Composition Features compose cleanly and automatically integrate with each other. Features can declare dependencies on other features or abstract services, and JustScale validates these at build time. What Features Provide Services - Business logic and data access Controllers - HTTP endpoints and CLI commands Middleware - Request processing and guards Dependencies - Automatic resolution of required features Feature Benefits Plug-and-play - Add complete functionality with one import Type-safe - Dependencies validated at compile time Testable - Features can be tested in isolation Composable - Features work together seamlessly Available Features Authentication The AuthFeature ships built-in User and Session models, password hashing, and the auth middleware for route protection. Permissions The PermissionFeature adds declarative, queryable access control — permit().when()rules live on the model and are used both as route guards and as automatic query filters. Channels (Pub/Sub) The ChannelFeature provides pub/sub messaging with async iterables. It requires an abstract backend that you can satisfy with MemoryChannelBackend or a Redis backend. Interactive Shell The ShellFeature provides an interactive REPL for your application. Think SSH, but you're connecting to the Node.js process itself to run commands and inspect state. Datastar (SSE) The Datastar integration provides Server-Sent Events streaming and reactive signal management for real-time updates. Creating Custom Features You can create your own features using createFeatureBuilder: Files srcfeaturesmy-feature.ts app.ts srcfeaturesmy-feature.ts app.ts src/features/my-feature.tsTypeScript ```typescript import { createFeatureBuilder, defineService, createController } from '@justscale/core'; import { Get } from '@justscale/http'; class MyService extends defineService({ inject: {}, factory: () => ({ getData: async () => ({ message: 'Hello from MyFeature!' }), }), }) {} const MyController = createController('/my-feature', { inject: { myService: MyService }, routes: (services) => ({ data: Get('/data').handle(async ({ res }) => { const data = await services.myService.getData(); res.json(data); }), }), }); export const MyFeature = createFeatureBuilder() .name('my-feature') .provides((b) => b .service(MyService) .controller(MyController) ); ``` Features with Dependencies Features can declare dependencies on abstract services. The cluster builder validates that all dependencies are satisfied: Files srcfeaturesnotification-feature.tsemail-backend.ts srcfeaturesnotification-feature.tsemail-backend.ts src/features/notification-feature.tsTypeScript ```typescript import { createFeatureBuilder, defineService, createAbstractServiceToken } from '@justscale/core'; // Abstract interface for notification backend export const NotificationBackend = createAbstractServiceToken<{ send: (userId: string, message: string) => Promise; }>('NotificationBackend'); class NotificationService extends defineService({ inject: { backend: NotificationBackend }, factory: ({ backend }) => ({ notify: (userId: string, msg: string) => backend.send(userId, msg), }), }) {} export const NotificationFeature = createFeatureBuilder() .name('notification') .requires(NotificationBackend) // Declares dependency .provides((b) => b.service(NotificationService)); ``` Next Steps Channels Shell Feature Datastar --- # Permissions URL: https://justscale.sh/docs/features/permissions Permissions Declarative, queryable model-level access control The @justscale/permission package lets you declare whocan do what on a model in one place — next to the fields the rule is about. The same rule is used both as a route guard and as a query filter, so list endpoints, row guards, and field-level access stay in sync without repeating yourself. Installation Bash ```bash pnpm add @justscale/permission ``` Three Moving Parts Permissions are three concepts that plug together: permit()— declares a rule on a model (“a Customer can close their own Ticket”). AbstractPrincipalProvider — a contribution token. Each resolver returns the principals the current request acts as (a User, a Customer, an Agent…). PermissionFeature — registers the runtime (PermissionService, explicit grants, the permissions middleware). Declare Permissions on a Model Use the permissions factory on defineModel. It receives the model's field expressions, so you can point at a field by name and the compiler checks it exists. Files srcmodelsagent.tscustomer.tsticket.ts principals.ts srcmodelsagent.tscustomer.tsticket.ts principals.ts src/models/ticket.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; import { permit } from '@justscale/permission'; import { Customer } from './customer'; import { Agent } from './agent'; export class Ticket extends defineModel({ fields: { subject: field.string().max(255), body: field.text(), customer: field.ref(Customer), assignedAgent: field.ref(Agent).optional(), status: field.enum('TicketStatus', ['open', 'resolved', 'closed']).default('open'), }, // The arg is the field-expression record — destructure what you need. permissions: ({ customer }) => ({ // Array = OR. A Customer who owns this ticket OR any Agent can view. view: [permit(Customer).when(customer), permit(Agent).always()], close: permit(Customer).when(customer), assign: permit(Agent).always(), }), }) {} ``` Three modes are built in: permit(Role).when(field)— the field on the resource must equal the principal's ref. Queryable — the repository can filter list endpoints automatically. permit(Role).always()— any principal of this type passes. Queryable — yields “no extra filter” in queries. permit(Role).check(fn) — a custom predicate. Runs as a guard; notqueryable (can't be pushed into SQL). Guard Routes with Model Permissions ticket.controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; import { auth } from '@justscale/auth'; import { Ticket } from '../models/ticket'; export const TicketController = createController({ routes: () => ({ close: Post('/tickets/:ticket/close') .types({ Ticket }) .use(auth) .guard(Ticket.can.close) // principal must satisfy the rule .handle(({ params, res }) => { // Guard passed: the authenticated Customer owns params.ticket res.status(204).end(); }), }), }); ``` The guard runs Ticket.can.close against the resolved :ticket param. On failure the response is 403. Resolve Principals A principal is just { type: Model, ref: Reference }. One request can act as several principals — AbstractPrincipalProvider is a contribution token, so each resolver stays small and composable. Files srcmodelsagent.tscustomer.tsticket.ts principals.ts srcmodelsagent.tscustomer.tsticket.ts principals.ts src/principals.tsTypeScript ```typescript import { createContribution } from '@justscale/core'; import { ModelRepository } from '@justscale/core/models'; import { AbstractPrincipalProvider } from '@justscale/permission'; import { User } from '@justscale/auth'; import { Customer } from './models/customer'; // Every authenticated request is a User principal. export const UserPrincipalResolver = createContribution(AbstractPrincipalProvider, { inject: {}, factory: () => ({ resolve(ctx: { user?: InstanceType }) { if (!ctx.user) return []; return [{ type: User, ref: User.ref(ctx.user) }]; }, }), }); // If the User also has a Customer record, contribute that principal too. export const CustomerPrincipalResolver = createContribution(AbstractPrincipalProvider, { inject: { customers: ModelRepository.of(Customer) }, factory: ({ customers }) => ({ async resolve(ctx: { user?: InstanceType }) { if (!ctx.user) return []; const customer = await customers.findOne( Customer.fields.email.eq(ctx.user.email), ); return customer ? [{ type: Customer, ref: Customer.ref(customer) }] : []; }, }), }); ``` The built-in aggregator flat-maps all contributions. Adding an AgentPrincipalResolver later is purely additive — no existing code changes. Query Filtering — the Same Rule Runs in SQL Because .when() and .always() rules produce a Condition, .toCondition(principal) is what repositories call to add the filter to list endpoints. There is no second place to keep in sync. list-my-tickets.tsTypeScript ```typescript // Ticket.can.view = [permit(Customer).when(customer), permit(Agent).always()] // // For a Customer principal, Ticket.can.view.toCondition(principal) → // EqCondition { field: 'customer', value: principal.ref.identifier } // // For an Agent principal, Ticket.can.view.toCondition(principal) → // AndCondition([]) — i.e. no filter, agents see everything. // // The repository applies the condition; the controller just guards the route. listMine: Get('/tickets') .use(auth) .use(permissions) .handle(async ({ tickets, principals }) => { const mine = await tickets.find({ where: Ticket.can.view.toCondition(principals[0]), }); return mine; }), ``` Permission-Scoped Responses The permissions middleware lets a single route return different schemas depending on which permission the caller satisfies. Declare each shape with .returns(status, schema, permission)and res.permission becomes a typed discriminant. employee.controller.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { auth } from '@justscale/auth'; import { permissions, assertNever } from '@justscale/permission'; import { Employee } from '../models/employee'; import { EmployeeFull, EmployeeLimited } from '../schemas/employee'; Get('/employees/:employee') .types({ Employee }) .use(auth) .use(permissions) .guard(Employee.can.view) .returns(200, EmployeeFull, Employee.can.fullAccess) .returns(200, EmployeeLimited, Employee.can.view) .handle(({ params, res }) => { const e = params.employee; // res.permission is typed as 'fullAccess' | 'view' switch (res.permission) { case 'fullAccess': res.json({ name: e.name, salary: e.salary, department: e.department }); return; case 'view': res.json({ name: e.name }); return; default: assertNever(res); // compile error if a case is missing } }); ``` The middleware walks the permission-scoped .returns()entries in declaration order and picks the first one whose rule matches the caller — so put the more privileged schema first. Wire It Up app.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { AuthFeature } from '@justscale/auth'; import { PermissionFeature } from '@justscale/permission'; import { UserPrincipalResolver, CustomerPrincipalResolver } from './principals'; import { TicketController } from './controllers/ticket'; const app = JustScale() .add(AuthFeature) .add(PermissionFeature) // requires AbstractPrincipalProvider + a PermissionGrant repo .add(UserPrincipalResolver) .add(CustomerPrincipalResolver) .add(TicketController) .build(); await app.serve({ http: 3000 }); ``` PermissionFeature requires a repository for PermissionGrant — the model that backs explicit grants issued through PermissionService. In production wire a createPgRepository(PgPermissionGrant); tests can use the in-memory repository. Best Practices One rule, two call sites. Use .guard(Model.can.x) for row-level access and .toCondition(p) for list filtering — never branch on roles in handlers. One principal resolver per principal type.Small contributions compose; monolithic providers don't. Prefer .when() over .check(). .when() is queryable; .check() blocks the list-filter path and forces a post-fetch check. Order permission-scoped returns by privilege. The middleware takes the first match — a broader rule listed first will shadow a narrower one. Next Steps Authentication Guards Middleware --- # Interactive Shell Feature URL: https://justscale.sh/docs/features/shell Interactive Shell Feature Connect to your running application with an interactive REPL The @justscale/feature-shell package provides an interactive shell for your application. Think SSH, but you're connecting to the Node.js process itself to run commands, inspect state, and interact with your services. Installation Bash ```bash pnpm add @justscale/feature-shell pnpm add @justscale/core/cli ``` Basic Setup src/app.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { ShellFeature } from '@justscale/feature-shell'; const app = JustScale() .add(ShellFeature()) .build(); await app.serve({ http: 3000 }); ``` Connect to the shell: Bash ```bash justscale shell ``` What You Get The shell feature automatically provides an interactive REPL with: Built-in Commands help - Show available commands info - Show application info (Node version, uptime, memory) health - Show health status ping - Check connection clear - Clear the screen exit or quit - Exit the shell Command Execution Any CLI command registered in your application can be executed from within the shell. This includes commands from features like AuthFeature. Using the Shell Start the shell and interact with your application: Bash ```bash $ justscale shell JustScale Interactive Shell Type 'help' for commands, 'exit' to quit. justscale> help Built-in commands: help Show this help message info Show app information health Show health status ping Check connection clear Clear the screen exit Exit the shell App commands: auth create-user auth list-users justscale> info ┌──────────────┬────────────────────┐ │ Field │ Value │ ├──────────────┼────────────────────┤ │ Node Version │ v20.10.0 │ │ Platform │ darwin │ │ Uptime │ 0h 5m 23s │ │ Memory (RSS) │ 45MB │ │ Memory (Heap)│ 12MB │ │ PID │ 12345 │ └──────────────┴────────────────────┘ justscale> health ┌─────────┬─────────┐ │ Check │ Status │ ├─────────┼─────────┤ │ Process │ healthy │ │ Memory │ healthy │ └─────────┴─────────┘ justscale> ping pong justscale> exit Goodbye! ``` With AuthFeature When combined with the AuthFeature, you can manage users directly from the shell: src/app.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { AuthFeature } from '@justscale/auth'; import { ShellFeature } from '@justscale/feature-shell'; const app = JustScale() .add(AuthFeature()) .add(ShellFeature()) .build(); await app.serve({ http: 3000 }); ``` Now you can create and manage users from the shell: Bash ```bash justscale> auth create-user --email admin@example.com --password secret123 User created successfully: ID: abc123 Email: admin@example.com justscale> auth list-users ┌────────┬─────────────────────┬──────────────────────┐ │ ID │ Email │ Created At │ ├────────┼─────────────────────┼──────────────────────┤ │ abc123 │ admin@example.com │ 2024-12-10T10:30:00Z │ └────────┴─────────────────────┴──────────────────────┘ ``` How It Works The shell connects to your running application via a Unix socket (or named pipe on Windows). This allows you to interact with the live process without restarting or making HTTP requests. Connection Flow Your app builds with JustScale().add(...).build() When you call app.serve(), it creates a Unix socket Running justscale shell connects to this socket Commands are sent to the server and executed in the same process Output is streamed back to your terminal in real-time ℹ️Info The shell uses the same CLI infrastructure as standalone commands, so any CLI controller you create automatically works in the shell. Creating Custom Shell Commands Add your own commands by creating CLI controllers: Files srccontrollersadmin.tsio-demo.tsusers.ts servicesstats.tsusers.ts srccontrollersadmin.tsio-demo.tsusers.ts servicesstats.tsusers.ts src/controllers/admin.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Cli } from '@justscale/core/cli'; import { StatsService } from '../services/stats'; export const AdminController = createController({ inject: { stats: StatsService }, routes: (services) => ({ clearCache: Cli('admin clear-cache') .describe('Clear all in-process caches') .handle(({ io }) => { services.stats.clearCache(); io.log('Cache cleared!'); }), stats: Cli('admin stats') .describe('Show request and cache stats') .handle(({ io }) => { const s = services.stats.get(); io.table([ { Metric: 'Total Requests', Value: s.requests }, { Metric: 'Cache Hits', Value: s.cacheHits }, { Metric: 'Cache Misses', Value: s.cacheMisses }, ]); }), reset: Cli('admin reset') .describe('Reset all stats to zero') .handle(({ io }) => { services.stats.reset(); io.log('Stats reset!'); }), }), }); ``` Use in the shell: Bash ```bash justscale> help App commands: admin clear-cache admin stats admin restart justscale> admin stats ┌────────────────┬────────┐ │ Metric │ Value │ ├────────────────┼────────┤ │ Total Requests │ 12,543 │ │ Cache Hits │ 8,932 │ │ Cache Misses │ 3,611 │ └────────────────┴────────┘ justscale> admin clear-cache Cache cleared! ``` Command Arguments Commands can accept arguments via flags and positional parameters: Files srccontrollersadmin.tsio-demo.tsusers.ts servicesstats.tsusers.ts srccontrollersadmin.tsio-demo.tsusers.ts servicesstats.tsusers.ts src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Cli } from '@justscale/core/cli'; import { z } from 'zod'; import { UserSearchService } from '../services/users'; const QueryArgsSchema = z.object({ limit: z.number().default(10), offset: z.number().default(0), search: z.string().optional(), }); export const UsersCliController = createController({ inject: { users: UserSearchService }, routes: (services) => ({ search: Cli('users search') .describe('Search users by email (with --limit / --search flags)') .input(QueryArgsSchema) .handle(({ io, args }) => { const rows = services.users.search({ search: args.search, limit: args.limit, offset: args.offset, }); io.table(rows.map((u) => ({ ID: u.id, Email: u.email, Created: u.createdAt }))); }), }), }); ``` Use in the shell: Bash ```bash justscale> users search --limit 5 --search john ┌────────┬──────────────────┬──────────────────────┐ │ ID │ Email │ Created │ ├────────┼──────────────────┼──────────────────────┤ │ 1 │ john@example.com │ 2024-12-10T10:00:00Z │ │ 2 │ johnny@test.com │ 2024-12-10T11:00:00Z │ └────────┴──────────────────┴──────────────────────┘ ``` IO Utilities The shell provides rich IO utilities for formatting output: Files srccontrollersadmin.tsio-demo.tsusers.ts servicesstats.tsusers.ts srccontrollersadmin.tsio-demo.tsusers.ts servicesstats.tsusers.ts src/controllers/io-demo.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Cli } from '@justscale/core/cli'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // Minimal stand-in — the example is about the IO API, not the work itself. async function someAsyncOperation(): Promise { await sleep(100); } export const DemoController = createController({ inject: {}, routes: () => ({ demo: Cli('demo') .describe('Walk through every surface of the CLI IO helpers') .handle(async ({ io }) => { // Simple logging — log/warn/error/debug (verbose-only). io.log('Regular message'); io.warn('Warning message (yellow)'); io.error('Error message (red, stderr)'); io.debug('Only printed when --verbose'); // Tables io.table([ { Name: 'Alice', Age: 30, City: 'NYC' }, { Name: 'Bob', Age: 25, City: 'SF' }, ]); // Prompts (interactive input). const name = await io.prompt('Enter your name:'); const confirmed = await io.confirm('Are you sure?'); io.log(`name=${name} confirmed=${confirmed}`); // Spinners — success/fail/stop live on the returned handle. const spinner = io.spinner('Loading...'); await someAsyncOperation(); spinner.success('Loaded'); // Progress bars — io.progress(label, total?) returns a bar you .update(). const bar = io.progress('Processing', 100); for (let i = 0; i <= 100; i++) { bar.update(i); await sleep(10); } bar.complete(); }), }), }); ``` Configuration The ShellFeature accepts optional configuration: shell-config.tsTypeScript ```typescript ShellFeature({ // Reserved for future options like: // - Custom prompt // - Additional built-in commands // - Auth requirements }); ``` Currently, no configuration is required. Future versions may add options for customizing the prompt, adding built-in commands, or requiring authentication. Security Considerations ⚠️Warning The shell provides direct access to your application's internals. In production environments, ensure the socket file has appropriate permissions and is not accessible to untrusted users. Best practices: Run the app as a dedicated user with restricted permissions Ensure the socket file is in a protected directory Consider adding authentication for sensitive commands Audit what commands are available in production Use separate configurations for dev vs production Use Cases Development Create test users without writing seed scripts Inspect application state and configuration Trigger background jobs manually Clear caches and reset state Operations Monitor application health and metrics Run database migrations Manage feature flags Inspect and modify system state Debugging Check service configurations Inspect dependency injection container Run diagnostic commands Test individual components in isolation Complete Example src/app.tsTypeScript ```typescript import JustScale, { createController, defineService } from '@justscale/core'; import { ShellFeature } from '@justscale/feature-shell'; import { Cli } from '@justscale/core/cli'; // Custom service class StatsService extends defineService({ inject: {}, factory: () => { let requests = 0; return { increment: () => requests++, get: () => requests, reset: () => { requests = 0; }, }; }, }) {} // Custom CLI controller const AdminController = createController({ inject: { stats: StatsService }, routes: (services) => ({ stats: Cli('admin stats').handle(({ io }) => { io.table([ { Metric: 'Total Requests', Value: services.stats.get() }, ]); }), reset: Cli('admin reset').handle(({ io }) => { services.stats.reset(); io.log('Stats reset!'); }), }), }); // Build app with shell const app = JustScale() .add(StatsService) .add(ShellFeature()) .add(AdminController) .build(); await app.serve({ http: 3000 }); console.log('Server running with interactive shell'); console.log('Run: justscale shell'); ``` Next Steps Features Overview Datastar CLI Usage --- # Channels URL: https://justscale.sh/docs/fundamentals/channels Channels Typed pub/sub messaging across services and nodes Channels provide distributed pub/sub messaging as a first-class primitive. A channel is cluster-aware by default — subscribers receive messages whether they live in the same process or on a different node. This makes channels the foundation for WebSocket broadcasting, event streaming, and any cross-service communication. Installation Bash ```bash npm install @justscale/core ``` Basic Usage Channels key off references, not strings. Define a model for the thing you're broadcasting to (here, Room), then create a channels service with createChannels() and inject it into your services: Files room.model.tsroom-channels.tschat-service.ts room.model.tsroom-channels.tschat-service.ts room.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; // The domain entity channel subscribers and publishers refer to. export class Room extends defineModel({ name: 'Room', fields: { name: field.string().max(100), }, }) {} ``` Subscribing to Channels The subscribe() method returns an async iterable that yields messages as they arrive. The subscription automatically cleans up when the loop exits: subscription-example.tsTypeScript ```typescript import { Room } from './room.model'; // Subscribe returns AsyncIterable + Disposable const subscription = channels.subscribe(Room.ref`123`); // Stream messages with for-await for await (const msg of subscription) { console.log(msg.type, msg.username); // Break to unsubscribe if (msg.type === 'leave') break; } // Subscription automatically cleaned up // Or manually unsubscribe subscription.unsubscribe(); ``` Subscriptions implement Disposable, so you can use the using keyword for automatic cleanup: using-example.tsTypeScript ```typescript import type { Ref } from '@justscale/core/models'; import { Room } from './room.model'; async function handleConnection(room: Ref) { // Subscription auto-disposes when function exits using subscription = channels.subscribe(room); for await (const msg of subscription) { client.send(msg); } } ``` Publishing Messages Use publish() to broadcast messages to all subscribers of a channel: publish-example.tsTypeScript ```typescript import { Room } from './room.model'; // Broadcast to all subscribers of Room(123) channels.publish(Room.ref`123`, { type: 'message', username: 'alice', content: 'Hello everyone!', timestamp: Date.now(), }); // Only subscribers receive the message // No subscribers? Message is dropped (fire-and-forget) ``` Channel API The channels instance provides these methods: subscribe(key) - Subscribe to a channel, returns async iterable publish(key, msg) - Broadcast to all local subscribers deliverRemote(key, msg) - Deliver message from remote node (for clusters) hasSubscribers(key) - Check if channel has any subscribers getActiveChannels() - List all channels with subscribers Cluster Integration For multi-node deployments, use hooks to synchronize channels across the cluster. Hooks are called when subscription state changes: cluster-channels.tsTypeScript ```typescript import { createChannels } from '@justscale/core'; import { ChatEvents } from './events'; import { Room } from './room.model'; export const RoomChannels = createChannels().withHooks({ // Called when first subscriber joins on this node. // Hooks sit at the infrastructure boundary — rebuild a typed ref // from the channel key before doing any domain work. onFirstSubscriber: (key) => { const room = Room.ref`${key}`; console.log('First subscriber to', room); // Register interest in `room` at cluster level }, // Called when last subscriber leaves on this node onLastUnsubscribe: (key) => { const room = Room.ref`${key}`; console.log('No more subscribers to', room); // Unregister `room` from cluster }, // Called when publishing locally onPublish: (key, msg) => { const room = Room.ref`${key}`; // Broadcast to other cluster nodes via event bus ChatEvents.emit(`room.${msg.type}`, { room, ...msg }); }, }); ``` Handling Remote Messages Use deliverRemote() to deliver messages from other cluster nodes. This only delivers to local subscribers without re-triggering the onPublish hook: event-handler.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { ChatEvents } from './events'; import { RoomChannels } from './room-channels'; export const ChatEventController = createController('/', { inject: { channels: RoomChannels }, routes: (services) => ({ // Handle events from other cluster nodes onRoomEvent: ChatEvents.on('room.*').handle(({ payload }) => { const { room, ...message } = payload; // Deliver to local subscribers only. `room` is a Ref // carried through the event bus — channels accept refs directly. services.channels.deliverRemote(room, message); }), }), }); ``` With WebSocket Channels integrate naturally with WebSocket for real-time streaming: websocket-channels.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Ws } from '@justscale/websocket'; import { ChatService } from './chat-service'; import { Room } from './room.model'; export const ChatController = createController('/chat', { inject: { chat: ChatService }, routes: (services) => ({ room: Ws('/room/:roomId').handle(async ({ messages, send, params }) => { // Boundary: turn the raw path param into a typed reference. const room = Room.ref`${params.roomId}`; // Subscribe to room messages const subscription = services.chat.subscribe(room); // Forward channel messages to WebSocket client // This runs concurrently with message handling const streamMessages = async () => { for await (const msg of subscription) { send(msg); } }; // Handle incoming WebSocket messages const handleMessages = async () => { for await (const msg of messages) { services.chat.broadcast(room, { type: 'message', username: msg.username, content: msg.content, timestamp: Date.now(), }); } }; // Run both concurrently await Promise.race([streamMessages(), handleMessages()]); // Cleanup subscription subscription.unsubscribe(); }), }), }); ``` 💡Tip See the WebSocket Rooms & Broadcasting guide for more patterns on combining channels with WebSocket. Type Safety Channels are fully typed. The message type flows through to subscriptions: typed-channels.tsTypeScript ```typescript import { Room } from './room.model'; interface ChatMessage { type: 'text' | 'image' | 'system'; sender: string; content: string; } const ChatChannels = createChannels(); // Subscription is typed const sub = channels.subscribe(Room.ref`1`); for await (const msg of sub) { // msg is ChatMessage if (msg.type === 'text') { console.log(msg.sender, msg.content); } } // Publish requires correct type channels.publish(Room.ref`1`, { type: 'text', sender: 'alice', content: 'Hello!', }); ``` Next Steps WebSocket Overview Event Bus Cluster --- # Controllers URL: https://justscale.sh/docs/fundamentals/controllers Controllers Group routes with shared dependencies and path prefixes Controllers group related routes under a path prefix and share injected dependencies. They're the glue between your services and the transport layer (HTTP, CLI, WebSocket, SSE). Creating a Controller Use createController with a path prefix, injected services, and a routes function. The routes function receives resolved dependencies and returns route definitions: src/controllers/ticket-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { ModelRepository } from '@justscale/core/models'; import { Get, Post } from '@justscale/http'; import { SSE } from '@justscale/sse'; import { auth } from '@justscale/auth'; import { Ticket } from '../domain/models'; import { HelpdeskService } from '../domain/helpdesk-service'; export const TicketController = createController('/tickets', { inject: { helpdesk: HelpdeskService, ticketsRepo: ModelRepository.of(Ticket), // needed to acquire the lock }, routes: ({ helpdesk, ticketsRepo }) => ({ // GET /tickets — list all list: Get('/') .use(auth) .handle(async ({ res }) => { res.json(await helpdesk.listAll()); }), // GET /tickets/:ticket — view single ticket get: Get('/:ticket') .types({ Ticket }) .use(auth) .guard(Ticket.can.view) .handle(async ({ params, res }) => { const ticket = await params.ticket; if (!ticket) return res.status(404).json({ error: 'Not found' }); res.json(ticket); }), // POST /tickets/:ticket/resolve — caller locks, passes proof to the service resolve: Post('/:ticket/resolve') .types({ Ticket }) .use(auth) .guard(Ticket.can.resolve) .handle(async ({ params, res, user }) => { using locked = await ticketsRepo.lock(params.ticket); if (!locked) return res.status(404).json({ error: 'Not found' }); await helpdesk.resolveTicket(locked, user.name); res.status(204).end(); }), // SSE /tickets/:ticket/events — real-time stream events: SSE('/:ticket/events') .types({ Ticket }) .handle(async function* ({ params }) { // params.ticket is Ref yield { event: 'connected', data: { ticket: Ticket.ref(params.ticket).identifier } }; }), }), }); ``` Path Prefix The first argument to createController is the path prefix. All routes are relative to it: prefix-example.tsTypeScript ```typescript const TicketController = createController('/tickets', { routes: () => ({ list: Get('/'), // GET /tickets get: Get('/:ticket'), // GET /tickets/:ticket resolve: Post('/:ticket/resolve'), // POST /tickets/:ticket/resolve events: SSE('/:ticket/events'), // SSE /tickets/:ticket/events }), }); ``` Dependency Injection The inject object declares what the controller needs. Dependencies are resolved from the container and passed to the routes function as a closure: ticket-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { ModelRepository } from '@justscale/core/models'; import { Customer, Agent, Ticket } from '../domain/models'; import { HelpdeskService } from '../application/helpdesk-service'; export const TicketController = createController('/tickets', { inject: { helpdesk: HelpdeskService, customers: ModelRepository.of(Customer), agents: ModelRepository.of(Agent), }, // Destructured services are available to all route handlers routes: ({ helpdesk, customers, agents }) => ({ list: Get('/').handle(async ({ res, user }) => { // Use the closure — no need to inject per-route const customer = await customers.findOne( Customer.fields.email.eq(user.email), ); if (customer) { res.json(await helpdesk.listForCustomer(Customer.ref(customer))); } else { res.json(await helpdesk.listAll()); } }), }), }); ``` ℹ️Info Notice there are no string IDs anywhere. The customer is found by email, then passed as a reference — Customer.ref(customer). A Persistent is itself a valid reference. This is how JustScale keeps IDs out of domain code. Transport Agnostic Controllers support multiple transports in the same definition. Import route factories from the transport package you need: multi-transport.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, Post } from '@justscale/http'; // HTTP routes import { SSE } from '@justscale/sse'; // Server-Sent Events import { Cli } from '@justscale/core/cli'; // CLI commands export const TicketController = createController('/tickets', { inject: { helpdesk: HelpdeskService }, routes: ({ helpdesk }) => ({ // HTTP — browser and API clients list: Get('/').handle(async ({ res }) => { res.json(await helpdesk.listAll()); }), // SSE — real-time event stream in browser events: SSE('/events').handle(async function* () { yield { event: 'connected', data: {} }; }), // CLI — `just tickets stats` in terminal stats: Cli('stats').handle(async ({ output }) => { const all = await helpdesk.listAll(); output.log(`Total tickets: ${all.length}`); }), }), }); ``` Registering Controllers Add controllers to your app builder. Dependencies are validated at compile time: app.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { TicketController } from './controllers/ticket-controller'; import { TicketRepository, CommentRepository } from './infrastructure/pg'; import { HelpdeskService } from './application/helpdesk-service'; const app = JustScale() .add(TicketRepository) .add(CommentRepository) .add(HelpdeskService) .add(TicketController) // TypeScript verifies all deps are satisfied .build(); ``` Best Practices One controller per domain entity — TicketController, CustomerController Keep handlers thin — delegate to services, don't inline business logic Use references, not IDs — pass Persistent or Reference to services, never raw strings Leverage .types() — transform URL params into references so handlers get Reference instead of string Next Steps Routes Middleware Guards --- # Features URL: https://justscale.sh/docs/fundamentals/features Features Composable, reusable application modules Features are self-contained modules that bundle services, controllers, and lifecycle hooks into reusable packages. They declare what they need (.requires()) and what they provide (.provides()), enabling modular composition with compile-time dependency checking. What is a Feature? A feature is created with createFeatureBuilder() — a fluent builder that produces a feature token you can .add() to your app. Features compose naturally: logging-feature.tsTypeScript ```typescript import { createFeatureBuilder } from '@justscale/core'; import { LoggerService } from './logger-service'; // Simple feature — provides a service export const LoggingFeature = createFeatureBuilder() .name('logging') .provides((b) => b.add(LoggerService)); // Use in app import JustScale from '@justscale/core'; const app = JustScale() .add(LoggingFeature) .build(); ``` Creating a Feature Use createFeatureBuilder() to define a feature. The .provides() callback receives a builder where you .add() services and controllers: user-feature.tsTypeScript ```typescript import { createFeatureBuilder } from '@justscale/core'; import { UserService, AuthService } from './services'; import { UsersController, AuthController } from './controllers'; export const UserFeature = createFeatureBuilder() .name('users') .provides((b) => b .add(UserService) .add(AuthService) .add(UsersController) .add(AuthController) ); // Use it import JustScale from '@justscale/core'; const app = JustScale() .add(UserFeature) .build(); ``` Feature Dependencies Features can depend on tokens or other features using .requires(). Dependencies must be provided before the feature is added: feature-dependencies.tsTypeScript ```typescript import { createFeatureBuilder, bindService } from '@justscale/core'; import { ModelRepository } from '@justscale/core/models'; import { User } from './models'; // Feature that requires a User repository and an email sender export const AuthFeature = createFeatureBuilder() .name('auth') .requires(ModelRepository.of(User)) .requires(AbstractEmailSender) .provides((b) => b .add(PasswordService) .add(UserService) .add(SessionService) .add(AuthController) ); // When adding AuthFeature, its requirements must already be met import JustScale, { bindRepository } from '@justscale/core'; const app = JustScale() .add(PgClient) .add(bindRepository(ModelRepository.of(User), UserRepository)) .add(bindService(AbstractEmailSender, ConsoleEmailSender)) .add(AuthFeature) // Requirements satisfied above .build(); ``` Lifecycle Hooks Features can run code when the app starts or stops using .onStart() and .onStop(): lifecycle-hooks.tsTypeScript ```typescript import { createFeatureBuilder } from '@justscale/core'; export const DatabaseFeature = createFeatureBuilder() .name('database') .onStart(async ({ resolve }) => { const client = resolve(PgClient); await client.connect(); console.log('Database connected'); }) .onStop(async () => { console.log('Database disconnected'); }) .provides((b) => b.add(PgClient)); ``` Requiring Other Features When a feature requires another feature, the required feature's provided tokens become available in the .provides() builder: requiring-features.tsTypeScript ```typescript import { createFeatureBuilder } from '@justscale/core'; // Base feature export const DatabaseFeature = createFeatureBuilder() .name('database') .provides((b) => b.add(PgClient)); // Feature that requires DatabaseFeature export const UserFeature = createFeatureBuilder() .name('users') .requires(DatabaseFeature) // PgClient is now available .provides((b) => b .add(UserRepository) // Can depend on PgClient .add(UserService) ); // In the app, add both import JustScale from '@justscale/core'; const app = JustScale() .add(DatabaseFeature) .add(UserFeature) // Works because DatabaseFeature is already added .build(); ``` Composing Features Build complex applications by layering features. Each feature focuses on one capability: composing.tsTypeScript ```typescript import JustScale, { bindService, bindRepository } from '@justscale/core'; import { ModelRepository } from '@justscale/core/models'; import { AuthFeature, AuthEndpointsFeature, User, Session } from '@justscale/auth'; const app = JustScale() // Infrastructure .add(PgClient) .add(PostgresLockFeature) .add(InMemoryProcessFeature) // Auth (requirements: User repo, Session repo, email sender) .add(bindRepository(ModelRepository.of(User), UserRepository)) .add(bindRepository(ModelRepository.of(Session), SessionRepository)) .add(bindService(AbstractEmailSender, ConsoleEmailSender)) .add(AuthFeature) .add(AuthEndpointsFeature) // Domain .add(TicketRepository) .add(TicketService) .add(TicketController) .build(); ``` Service Bindings in Features Features can use bindService and bindRepository inside their .provides() callback to wire abstract tokens to implementations: service-bindings.tsTypeScript ```typescript import { createFeatureBuilder, bindService } from '@justscale/core'; // Abstract service abstract class AbstractLogger { abstract log(message: string): void; } // Concrete implementation class ConsoleLogger extends defineService({ inject: {}, factory: () => ({ log: (message: string) => console.log(message), }), }) {} // Feature binds abstract to concrete export const LoggingFeature = createFeatureBuilder() .name('logging') .provides((b) => b .add(ConsoleLogger) .add(bindService(AbstractLogger, ConsoleLogger)) ); // Other services can now inject AbstractLogger class UserService extends defineService({ inject: { logger: AbstractLogger }, factory: ({ logger }) => ({ create: (user) => { logger.log('Creating user'); return user; }, }), }) {} ``` Official Features JustScale provides official features for common functionality: @justscale/auth — Authentication, sessions, 2FA, password reset @justscale/feature-otel — OpenTelemetry tracing and metrics @justscale/feature-shell — Interactive CLI shell for debugging official-features.tsTypeScript ```typescript import JustScale, { bindRepository, bindService } from '@justscale/core'; import { ModelRepository } from '@justscale/core/models'; import { AuthFeature, AuthEndpointsFeature, User, Session, AbstractEmailSender, ConsoleEmailSender, } from '@justscale/auth'; const app = JustScale() .add(PgClient) .add(bindRepository(ModelRepository.of(User), PgUserRepository)) .add(bindRepository(ModelRepository.of(Session), PgSessionRepository)) .add(bindService(AbstractEmailSender, ConsoleEmailSender)) .add(AuthFeature) .add(AuthEndpointsFeature) .build(); ``` Best Practices One feature per domain — group related functionality together Explicit dependencies — use .requires() to declare what a feature needs, so TypeScript catches missing dependencies at compile time Separate services from endpoints — provide services in one feature and controllers in another (like AuthFeature + AuthEndpointsFeature) so consumers can build their own endpoints Use lifecycle hooks — .onStart() for initialization, .onStop() for cleanup Keep features focused — a feature should represent one clear capability Next Steps Services Features Overview OpenAPI --- # Guards URL: https://justscale.sh/docs/fundamentals/guards Guards Access control with guards and model permissions Guards gate access to routes. They run after middleware and can deny the request. JustScale supports inline guard functions, DI-injected guards, and declarative model permissions via Ticket.can.resolve. Model Permissions The most common guard pattern uses model-level permissions declared with permit(). The model defines who can do what: src/domain/ticket.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; import { permit } from '@justscale/permission'; import { Customer } from './customer'; import { Agent } from './agent'; export class Ticket extends defineModel({ fields: { subject: field.string(), status: field.enum('TicketStatus', ['open', 'in_progress', 'resolved', 'closed'] as const), customer: field.ref(Customer), assignedAgent: field.ref(Agent).optional(), }, permissions: ({ customer }) => ({ // Customers see their own tickets, agents see all view: [permit(Customer).when(customer), permit(Agent).always()], // Only agents can assign and resolve assign: permit(Agent).always(), resolve: permit(Agent).always(), // Only the owning customer can close close: permit(Customer).when(customer), }), }) {} ``` Then use Ticket.can.resolve as a guard on routes: src/controllers/guarded-routes.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; import { auth } from '@justscale/auth'; import { Ticket } from '../domain/models'; import { HelpdeskService } from '../services/helpdesk'; export const TicketController = createController('/tickets', { inject: { helpdesk: HelpdeskService }, routes: ({ helpdesk }) => ({ // Only agents can resolve tickets resolve: Post('/:ticket/resolve') .types({ Ticket }) .use(auth) .guard(Ticket.can.resolve) // 403 if not an agent .handle(async ({ params, user, res }) => { const ticket = await params.ticket; await helpdesk.resolve(ticket, user.name); res.status(204).end(); }), // Only the owning customer can close close: Post('/:ticket/close') .types({ Ticket }) .use(auth) .guard(Ticket.can.close) // 403 if not the ticket's customer .handle(async ({ params, res }) => { const ticket = await params.ticket; await helpdesk.close(ticket); res.status(204).end(); }), }), }); ``` ℹ️Info Ticket.can.close with permit(Customer).when(customer) checks that the authenticated user is the customer who created the ticket. The framework resolves the principal, loads the ticket, and compares the reference — all automatically. Inline Guards For simple checks, use an inline function. It receives the accumulated context: inline-guard.tsTypeScript ```typescript import { Post } from '@justscale/http'; import { auth } from '@justscale/auth'; // Only users with @support.com emails Post('/tickets/:ticket/assign') .use(auth) .guard(({ user, stop }) => { if (!user.email.endsWith('@support.com')) { return stop(); // Returns 403 Forbidden } }) .handle(async ({ params, res, user }) => { // Only reached if guard passed res.status(204).end(); }); ``` Combining Guards Chain multiple .guard() calls — all must pass. They execute in order: src/controllers/combined-guards.tsTypeScript ```typescript import { Post } from '@justscale/http'; import { auth } from '@justscale/auth'; import { Ticket } from '../domain/models'; Post('/:ticket/resolve') .types({ Ticket }) .use(auth) .guard(Ticket.can.resolve) // 1. Must be an agent .guard(async ({ params }) => { // 2. Ticket must be open const ticket = await params.ticket; if (ticket?.status === 'closed') throw new Error('Ticket already closed'); }) .handle(async ({ params, res, user }) => { // Both guards passed res.status(204).end(); }); ``` OR Guards (Array) Pass an array of guards for OR semantics — any matching guard allows access: src/controllers/or-guard.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { auth } from '@justscale/auth'; import { Ticket } from '../domain/models'; // Customer who owns the ticket OR any agent Get('/:ticket') .types({ Ticket }) .use(auth) .guard(Ticket.can.view) // Array permission — either rule matches .handle(async ({ params, res }) => { const ticket = await params.ticket; res.json(ticket); }); ``` Best Practices Use model permissions — declare permit() rules on the model, use Model.can.action as guards. One source of truth. Auth before guards — always chain .use(auth) before .guard(). Guards need to know who's asking. Guards don't add context — they check and deny. Use .use() for adding data to context. Prefer stop() over throw — stop() returns a clean 403. Throwing gives 500 unless you set statusCode. Next Steps Middleware Routes Typed Parameters --- # Distributed Locks URL: https://justscale.sh/docs/fundamentals/locks Distributed Locks Prevent race conditions with type-safe distributed locking Locks provide distributed mutual exclusion for persistent entities. A Lock is a branded type that proves exclusive access has been acquired, allowing functions to require lock proof as a parameter — preventing deadlocks from nested acquisition attempts. Core Principle Lock is always Persistent — you can only lock something that exists outside this instance. The lock type proves you have exclusive access, and functions can require this proof in their signatures. Basic Usage Use the using keyword to automatically release locks when done: lock-example.tsTypeScript ```typescript import { LockServiceDef } from '@justscale/core'; const lockService = container.resolve(LockServiceDef); // Acquire lock on an existing entity using user = await lockService.acquire(existingUser); // Chain with repository call using user = await lockService.acquire(userRepo.get(User.ref`${id}`)); if (!user) throw new NotFoundError(); // With options using user = await lockService.acquire(user, { ttl: 60000, // Lock expires after 60s timeout: 10000 // Wait up to 10s to acquire }); ``` Automatic Release Lock implements Disposable, so the using keyword automatically releases the lock when the scope ends: automatic-release.tsTypeScript ```typescript { using user = await lockService.acquire(existingUser); // ... do work with exclusive access ... } // Lock released automatically here ``` Requiring Lock Proof The real power of Lock is requiring it in function signatures. This prevents deadlocks from nested acquisition and makes lock requirements explicit: payment-service.tsTypeScript ```typescript import { Lock, Persistent } from '@justscale/core/models'; class PaymentService { // Function REQUIRES lock proof — caller must acquire before calling async processRefund(user: Lock>, amount: number) { // Type proves caller owns the lock // No nested locking needed = no deadlocks user.balance += amount; await this.userRepo.save(user); } } // Usage: using user = await lockService.acquire(userRepo.get(User.ref`${id}`)); if (!user) throw new NotFoundError(); await paymentService.processRefund(user, 100); // Type-safe! ``` Why This Pattern? Prevents deadlocks — If serviceA calls serviceB and both try to lock the same entity, you get a deadlock. By requiring Lock as a parameter, locking happens at the top level and proof flows down. Self-documenting — Function signatures clearly show what requires exclusive access Compile-time safety — TypeScript prevents calling locked functions without a lock Repository Integration The Repository enforces lock requirements for write operations. You can't save or delete a persistent entity without locking it first: repository-locking.tsTypeScript ```typescript // COMPILE ERROR — can't save a plain Persistent const user = await userRepo.get(User.ref`${id}`); user.name = 'Bob'; await userRepo.save(user); // Type error! // Must lock first using user = await lockService.acquire(userRepo.get(User.ref`${id}`)); user.name = 'Bob'; await userRepo.save(user); // OK - user is Lock> // New entities don't need locks await userRepo.save({ name: 'Alice', email: 'alice@example.com' }); ``` Repository Method Signatures repository-signatures.tsTypeScript ```typescript abstract class Repository, TId = string> { // Read operations — no lock required abstract find(options?: FindOptions): Promise[]>; abstract get(ref: Ref): Promise | null>; abstract findOne(where: Partial): Promise | null>; // Write operations — require lock OR new entity abstract save(entity: Transient | Lock>): Promise>; // Delete — requires lock abstract delete(entity: Lock>): Promise; abstract deleteById(id: TId): Promise; // No lock - idempotent } ``` Lock Is a Fresh Read Acquiring a lock is not just a mutex operation — it is an atomic lock-and-read. Whatever Persistent you held before calling lock() is discarded; the Locked you get back is built from a row that was just re-read under the lock. There is no separate SELECT followed by an acquire — it is one statement. The Postgres adapter implements this with a single SELECT ... FOR UPDATE: pg-repository-lock.tsTypeScript ```typescript // packages/adapters/postgres/src/repository/pg-repository.ts async lock(entity: Ref): Promise | null> { const id = extractId(entity) // Row-level lock + fresh read in one statement. // No other session can modify this row until we release. const result = await sql` SELECT * FROM ${sql(this.tableName)} WHERE id = ${id} FOR UPDATE ` if (result.length === 0) return null // The Locked is built from the POST-lock row — not // from anything the caller passed in. const fresh = this.rowToEntity(result[0]) return brandLocked(fresh) } ``` 💡Tip The invariant: every time you hold a Locked, its contents are authoritative as of the moment the lock was acquired. No other process can have modified it since, because no other session can hold the lock concurrently. What this eliminates No optimistic-concurrency retries.There is no version column, no CAS loop, no "save failed, re-fetch and try again" ceremony. If you hold the lock, your read is current and your write will apply. Read-before-lock is not a footgun. A common pattern is to findOne an entity, decide based on its state whether to mutate, then lock it. That is safe: the initial read serves as an ID carrier and a cheap branch; the Locked you operate on came from the post-lock re-read, which may have different contents than what you first saw. Caches cannot poison mutation paths. You can cache Persistent freely — the moment you need to mutate, you call lock(), which re-reads. The cache only ever affects read paths; the write path always sees fresh data. No stale Locked can exist. Locked is scope-bound via Symbol.dispose — the moment the lock releases, the brand goes with it. You cannot store a Locked in a long-lived cache; the type system forbids smuggling it past its using scope. The consequence Combined with the type-level requirement that every mutating repository method takes Locked, this means: in a JustScale app, every write happens on data that was re-read atomically with the lock that protects it.Stale-write bugs — the kind that usually force frameworks to ship version columns, retry loops, or "last writer wins" warnings in the docs — are structurally impossible under the repo API. Setup Add a lock feature to the app builder. It provides the abstract LockService that services can inject. src/app.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { InMemoryLockFeature } from '@justscale/core/memory'; import { defaultHttpConfig } from '@justscale/http/testing'; // import { UserController } from './controllers/user'; const app = JustScale() .add(defaultHttpConfig) .add(InMemoryLockFeature) // provides AbstractLockProvider // .add(UserController) .build(); await app.serve(); ``` Use PostgresLockFeature from @justscale/postgres for multi-node deployments. Lock Options Configure lock behavior with options: lock-options.tsTypeScript ```typescript interface LockOptions { ttl?: number; // Time-to-live in ms (default: 30000) timeout?: number; // Acquisition timeout in ms (default: 5000) key?: string; // Custom lock key (default: derived from entity) } // Example with all options using user = await lockService.acquire(existingUser, { ttl: 60000, // Lock expires after 60 seconds timeout: 10000, // Wait up to 10 seconds to acquire key: 'user:123' // Custom key (usually auto-derived) }); ``` ttl — Safety net if release fails. Lock expires automatically after this time. timeout — How long to wait if another process holds the lock. key — Usually derived from entity ID. Custom keys for special cases. Lock Providers In-Memory InMemoryLockFeature from @justscale/core/memory — single-node apps, development, and tests. All locks live in process memory; nothing crosses node boundaries. Postgres (advisory locks) PostgresLockFeature from @justscale/postgres — distributed locking via PostgreSQL advisory locks. Same LockService interface, but locks are held against the database and release automatically if the session dies. Redis (Coming Soon) Redis-based locking with the Redlock algorithm — on the roadmap. Double-Lock Detection Trying to acquire a lock you already hold in the same async context throws DoubleLockError. This prevents silent deadlocks from re-entrant acquisition — e.g. a helper function that locks an entity already locked by its caller, or a process handler that receives a signal carrying an entity reference and then tries to lock it again. double-lock.tsTypeScript ```typescript using a = await repo.lock(orderRef) using b = await repo.lock(orderRef) // → DoubleLockError: Cannot acquire lock "lock:Order:abc" // — already held in this async context. // Fix: pass the existing Lock to the helper, don't re-lock async function charge(order: Locked, amount: string) { await repo.update(order, { charged: amount }) } using order = await repo.lock(orderRef) await charge(order, '100.00') // No re-lock — type proves ownership ``` Tracking is scoped to the current async context via AsyncLocalStorage: separate requests and separate process executions each get their own held-locks set, so concurrent workers don't false-positive on each other. Inside durable process handlers a DoubleLockError is treated as recoverable — the process stays suspended at the prior step with a lastError marker instead of transitioning to failed, so a fix-and-redeploy can rescue the in-flight state. See the Processes Runtime page for details. Design Notes Why using instead of await using? Lock release is fire-and-forget. We don't need to await the unlock because: Lock has TTL — worst case it expires naturally Unlock failure is recoverable (just slower for next acquirer) using is cleaner syntax than await using Why accept Promise? Allows natural chaining without intermediate variables: promise-chaining.tsTypeScript ```typescript // Instead of: const user = await userRepo.get(User.ref`${id}`); using lockedUser = await lockService.acquire(user); // Just: using user = await lockService.acquire(userRepo.get(User.ref`${id}`)); ``` Next Steps Repositories Overview Testing --- # Middleware URL: https://justscale.sh/docs/fundamentals/middleware Middleware Context accumulation with type-safe middleware Middleware extends the handler context with new properties. Each .use() call adds to what came before — TypeScript tracks the accumulated context so your handler sees exactly what's available. Inline Middleware The simplest middleware is an inline function that returns an object. The returned properties are merged into the context: inline-middleware.tsTypeScript ```typescript import { Get } from '@justscale/http'; Get('/tickets') .use(() => ({ requestedAt: Date.now() })) .handle(({ requestedAt, res }) => { // requestedAt is typed as number — added by the middleware res.json({ requestedAt }); }); ``` Context Accumulation Multiple .use() calls accumulate context. Each middleware sees everything that came before it: accumulation.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { auth } from '@justscale/auth'; Get('/tickets/:ticket') .use(auth) // ctx now has: { user } .use(async ({ user }) => { // can access user from previous .use() const role = user.email.endsWith('@support.com') ? 'agent' : 'customer'; return { role }; // adds: { role } }) .handle(({ user, role, params, res }) => { // Both user and role are available and typed if (role === 'agent') { res.json({ tickets: 'all' }); } else { res.json({ tickets: 'own' }); } }); ``` Async Middleware Middleware can be async — useful for loading data that the handler needs: async-middleware.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { auth } from '@justscale/auth'; // Async middleware — fetches additional data before the handler runs Get('/dashboard') .use(auth) .use(async ({ user }) => { // Fetch preferences from an external API const res = await fetch(`https://api.example.com/prefs/${user.email}`); const preferences = await res.json(); return { preferences }; // adds { preferences } to context }) .handle(({ user, preferences, res }) => { // Both user (from auth) and preferences (from async middleware) available res.json({ user: user.email, theme: preferences.theme }); }); ``` DI Middleware (MiddlewareDef) For middleware that needs injected services, use createMiddleware. This lets middleware participate in the DI system: Files srcmiddlewareaudit-log.ts controllersusage.ts srcmiddlewareaudit-log.ts controllersusage.ts src/middleware/audit-log.tsTypeScript ```typescript import { createMiddleware, Logger } from '@justscale/core'; // Middleware with DI — Logger is injected automatically export const auditLog = createMiddleware({ inject: { logger: Logger }, handler: ({ logger }) => async (ctx: { user?: { email: string } }) => { const who = ctx.user?.email ?? 'anonymous'; logger.info('Request', { user: who }); return { auditedAt: new Date() }; }, }); ``` The auth Middleware The most common middleware is auth from @justscale/auth. It validates the session token and adds user to the context: auth-usage.tsTypeScript ```typescript import { Get, Post } from '@justscale/http'; import { auth } from '@justscale/auth'; // After .use(auth), the handler has { user: Persistent } Get('/tickets') .use(auth) .handle(({ user, res }) => { // user.email, user.name — typed fields from the User model res.json({ loggedInAs: user.email }); }); // Without auth — no user in context Get('/health').handle(({ res }) => { res.json({ status: 'ok' }); }); ``` The permissions Middleware Stack permissions from @justscale/permission after auth when a route uses permission-scoped .returns(). It resolves the caller's principals, stores them for downstream query filtering, and wraps res with a .permission discriminant matching the rule that fired. permissions-usage.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { auth } from '@justscale/auth'; import { permissions, assertNever } from '@justscale/permission'; import { Employee } from '../models/employee'; import { EmployeeFull, EmployeeLimited } from '../schemas/employee'; Get('/employees/:employee') .types({ Employee }) .use(auth) .use(permissions) .guard(Employee.can.view) .returns(200, EmployeeFull, Employee.can.fullAccess) .returns(200, EmployeeLimited, Employee.can.view) .handle(({ params, res }) => { switch (res.permission) { case 'fullAccess': res.json(params.employee); return; case 'view': res.json({ name: params.employee.name }); return; default: assertNever(res); } }); ``` See Permissions for the full picture — declaring rules on models and querying with them. Best Practices Middleware adds, never removes — each .use() extends the context. It can't remove properties added by previous middleware Order matters — later middleware can access earlier additions. .use(auth).use(loadProfile) works; the reverse doesn't Use DI middleware for shared logic — if middleware needs services, use createMiddleware instead of closures Throw to stop — if middleware throws, execution stops and the error is returned to the client Next Steps Guards Routes Controllers --- # Routes URL: https://justscale.sh/docs/fundamentals/routes Routes Route builders with middleware, guards, and handlers Routes define individual endpoints in your controllers. JustScale uses a fluent builder API that lets you chain middleware, guards, and handlers with full type safety at every step. Route Builders Import route factories from the transport package: Get, Post, Put,Delete, Patch from @justscale/http, or SSE from @justscale/sse. src/controllers/ticket-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, Post } from '@justscale/http'; import { SSE } from '@justscale/sse'; import { Ticket, HelpdeskService } from '../domain/models'; export const TicketController = createController('/tickets', { inject: { helpdesk: HelpdeskService }, routes: ({ helpdesk }) => ({ list: Get('/'), get: Get('/:ticket'), create: Post('/'), resolve: Post('/:ticket/resolve'), close: Post('/:ticket/close'), events: SSE('/:ticket/events'), }), }); ``` Path Parameters Define path parameters with :paramName syntax. They're automatically extracted and typed in the handler context: path-params.tsTypeScript ```typescript import { Get } from '@justscale/http'; // Single param Get('/:ticket').handle(async ({ params }) => { params.ticket; // string — inferred from the path }); // Multiple params Get('/:ticket/comments/:commentId').handle(async ({ params }) => { params.ticket; // string params.commentId; // string }); ``` Typed Parameters with .types() Use .types() to transform path parameters from strings into model references. This is the JustScale way — no raw IDs in your handlers: src/controllers/typed-params.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { Ticket } from '../domain/models'; // Without .types() — params.ticket is a string Get('/:ticket').handle(async ({ params }) => { params.ticket; // string — a raw URL segment }); // With .types() — params.ticket is Reference Get('/:ticket') .types({ Ticket }) .handle(async ({ params }) => { const ticket = await params.ticket; // Persistent | undefined ticket?.subject; // typed field access }); ``` ℹ️Info .types({ Ticket }) matches :ticket, :Ticket, and :ticketRef. Each request gets a fresh Reference — no cross-request caching. The .handle() Method Every route ends with .handle(). The handler receives a context object with transport-specific fields and anything added by middleware: src/controllers/handlers.tsTypeScript ```typescript import { Get, Post } from '@justscale/http'; import { z } from 'zod'; import { Ticket } from '../domain/models'; // GET handler — read a ticket Get('/:ticket') .types({ Ticket }) .handle(async ({ params, res }) => { const ticket = await params.ticket; if (!ticket) return res.status(404).json({ error: 'Not found' }); res.json(ticket); }); // POST handler — create with validated body const CreateTicketBody = z.object({ subject: z.string().min(1), body: z.string(), priority: z.enum(['low', 'medium', 'high', 'critical']), }); Post('/') .body(CreateTicketBody) .returns(201) .handle(async ({ body, res }) => { // body is typed: { subject: string, body: string, priority: ... } const ticket = await helpdesk.createTicket( body.subject, body.body, body.priority ); res.status(201).json(ticket); }); ``` The .use() Method — Middleware Chain middleware with .use(). Each middleware adds properties to the context, which accumulate through the chain: middleware-chain.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { auth } from '@justscale/auth'; Get('/:ticket') .use(auth) // adds { user: User } to context .use(loadCustomer) // adds { customer: Customer } .handle(({ user, customer, params, res }) => { // Both user and customer are available and typed res.json({ user: user.name, customer: customer.name }); }); ``` The .guard() Method Guards gate access to a route. They run after middleware and can stop execution. Use model permissions for declarative authorization: src/controllers/guarded-routes.tsTypeScript ```typescript import { Post } from '@justscale/http'; import { auth } from '@justscale/auth'; import { Ticket } from '../domain/models'; // Permission guard — only agents can resolve Post('/:ticket/resolve') .types({ Ticket }) .use(auth) .guard(Ticket.can.resolve) .handle(async ({ params, res, user }) => { const ticket = await params.ticket; if (!ticket) return res.status(404).json({ error: 'Not found' }); await helpdesk.resolveTicket(ticket, user.name); res.status(204).end(); }); ``` Schema Validation Use Zod schemas with .body() for automatic request validation and .returns() for response type declarations: src/controllers/validated-route.tsTypeScript ```typescript import { Post } from '@justscale/http'; import { auth } from '@justscale/auth'; import { CreateTicketBody, TicketSchema, ErrorSchema } from './schemas'; Post('/') .use(auth) .body(CreateTicketBody) .returns(201, TicketSchema) .returns(400, ErrorSchema) .handle(async ({ body, res, user }) => { // body is typed: { subject: string, body: string, priority: 'low' | ... } // Invalid bodies are rejected with 400 before the handler runs const ticket = await helpdesk.createTicket( body.subject, body.body, body.priority, Customer.ref(user), ); res.status(201).json(ticket); }); ``` SSE Routes SSE routes use the same builder API but the handler is an async generator: src/controllers/sse-route.tsTypeScript ```typescript import { SSE } from '@justscale/sse'; import { Ticket } from '../domain/models'; SSE('/:ticket/events') .types({ Ticket }) .handle(async function* ({ params }) { const ticket = await params.ticket; if (!ticket) { yield { event: 'error', data: { message: 'Not found' } }; return; } yield { event: 'connected', data: { subject: ticket.subject } }; for await (const event of ticket.events) { yield { event: event.type, data: event.data }; } }); ``` Route Execution Order When a request comes in, steps execute in the order they were chained: 1. .use() steps — middleware adds to context, in order 2. .guard() steps — guards check access, in order 3. .handle() — the handler runs with the full accumulated context If any guard denies access, the handler never runs. If middleware throws, execution stops. Steps can be interleaved — .use(auth).guard(check).use(loadProfile).handle(). Best Practices Use .types() — transform URL params into references. Handlers get Reference, not raw strings Validate early — use .body() before guards to reject invalid input fast Chain thoughtfully — parse → validate → guard → handle Keep handlers thin — delegate to services. The handler orchestrates, it doesn't contain business logic Next Steps Middleware Guards Typed Parameters --- # Services URL: https://justscale.sh/docs/fundamentals/services Services Type-safe dependency injection with services Services are the foundation of JustScale's dependency injection system. They encapsulate business logic, data access, and shared functionality that can be injected into controllers and other services. Creating a Service Use defineService to create a service class with its dependencies and factory function. The factory receives resolved dependencies and returns your service instance: notification-service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; export class NotificationService extends defineService({ inject: {}, factory: () => ({ sendTicketCreated: async (email: string, subject: string) => { console.log(`[Notification] New ticket "${subject}" for ${email}`); }, sendTicketResolved: async (email: string, subject: string) => { console.log(`[Notification] Ticket "${subject}" resolved — ${email}`); }, }), }) {} ``` Injecting Dependencies Services can depend on other services and repositories. List dependencies in the inject object, and they'll be automatically resolved and passed to your factory function: src/application/helpdesk-service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import { ModelRepository, type Locked, type Persistent, type Ref } from '@justscale/core/models'; import { Ticket, Customer, Comment } from '../domain/models'; export class HelpdeskService extends defineService({ inject: { tickets: ModelRepository.of(Ticket), comments: ModelRepository.of(Comment), }, factory: ({ tickets, comments }) => ({ async createTicket( subject: string, body: string, priority: 'low' | 'medium' | 'high' | 'critical', customer: Ref, ) { return tickets.insert({ subject, body, priority, customer }); }, async addComment( ticket: Persistent, body: string, authorType: 'customer' | 'agent', authorName: string, ) { return comments.insert({ ticket, body, authorType, authorName }); }, // Mutations require Locked — the caller acquires and passes proof. async resolveTicket(ticket: Locked) { return tickets.update(ticket, { status: 'resolved' }); }, listForCustomer(customer: Ref) { return tickets.find({ where: Ticket.fields.customer.eq(customer) }); }, listAll() { return tickets.find({}); }, }), }) {} ``` The HelpdeskService injects two repositories — tickets and comments. JustScale resolves them automatically when the service is first needed. Factory Pattern The factory function is called once when the service is first needed. The returned object becomes the singleton instance that gets injected everywhere: notification-service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; export class NotificationService extends defineService({ inject: {}, factory: () => { // This runs once at startup const emailConfig = { from: process.env.EMAIL_FROM || 'support@helpdesk.com', smtpHost: process.env.SMTP_HOST || 'localhost', }; return { sendTicketCreated: async (email: string, subject: string) => { // Use emailConfig — captured once in the closure console.log(`[${emailConfig.from}] New ticket: ${subject}`); }, sendTicketResolved: async (email: string, subject: string) => { console.log(`[${emailConfig.from}] Resolved: ${subject}`); }, }; }, }) {} ``` Using the Resolver The factory function receives a second parameter: a resolve function. Use this to dynamically resolve dependencies that aren't known at compile time: src/application/principal-provider.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import { ModelRepository } from '@justscale/core/models'; import { Customer, Agent } from '../domain/models'; export class HelpdeskPrincipalProvider extends defineService({ inject: {}, factory: (_, resolve) => ({ async resolve(user: { email: string }) { // Dynamically resolve repositories at call time const customers = await resolve(ModelRepository.of(Customer)); const agents = await resolve(ModelRepository.of(Agent)); const customer = await customers.findOne( Customer.fields.email.eq(user.email), ); if (customer) return { type: 'customer', ref: Customer.ref(customer) }; const agent = await agents.findOne( Agent.fields.email.eq(user.email), ); if (agent) return { type: 'agent', ref: Agent.ref(agent) }; return null; }, }), }) {} ``` Service Scopes By default, services are singletons — created once and shared across the entire application. The same instance is injected everywhere it's needed. Logger Exception The built-in Logger is the only non-singleton service. Each service that injects it receives its own logger instance with contextual information: helpdesk-service.tsTypeScript ```typescript import { defineService, Logger } from '@justscale/core'; import { ModelRepository } from '@justscale/core/models'; import { Ticket } from '../domain/models'; export class HelpdeskService extends defineService({ inject: { tickets: ModelRepository.of(Ticket), logger: Logger, }, factory: ({ tickets, logger }) => ({ async createTicket(subject: string, priority: string) { logger.info('Creating ticket', { subject, priority }); const ticket = await tickets.insert({ subject, priority }); logger.info('Ticket created', { ticketId: ticket }); return ticket; }, }), }) {} ``` Type Safety JustScale validates all dependencies at compile time. If you forget to provide a required dependency, TypeScript shows an error before your code runs: Files srcapplicationhelpdesk-service.ts domainticket.ts infrastructurepg.ts app.broken.tsapp.ts srcapplicationhelpdesk-service.ts domainticket.ts infrastructurepg.ts app.broken.tsapp.ts src/app.broken.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { HelpdeskService } from './application/helpdesk-service'; // HelpdeskService declares `inject: { tickets: ModelRepository.of(Ticket), comments: ... }`. // The cluster builder tracks provided vs required tokens at the type level — // calling .add(HelpdeskService) before its repositories are registered returns // `MissingDepsError<..., ModelRepository.of(Ticket) | ModelRepository.of(Comment)>` // instead of a builder, and .build() refuses to compile against that. const broken = JustScale() .add(HelpdeskService) .build(); void broken; ``` Built-in Logger JustScale provides a built-in Logger service that supports structured logging and context propagation. Each injection site gets its own logger scoped to the service name. Log Levels log-levels.tsTypeScript ```typescript logger.debug('Detailed debug info', { data }); // Development details logger.info('Ticket created', { ticketId }); // Normal operations logger.warn('SLA breach approaching', { ticket }); // Potential issues logger.error('Failed to send email', { error }); // Errors ``` Context Propagation Add context that propagates through the entire async tree: request-handler.tsTypeScript ```typescript import { Logger } from '@justscale/core'; async function handleRequest(logger: Logger, request: Request) { await logger.withContext( { requestId: request.id, userId: request.userId }, async () => { logger.info('Processing ticket creation'); // Has requestId, userId await createTicket(); // Nested calls too logger.info('Ticket created'); // Has requestId, userId } ); } ``` Best Practices Keep services focused — each service should have a single, clear responsibility Inject repositories, not raw database — services depend on abstract ModelRepository, not SQL Return plain objects — the factory returns methods, not class instances Use structured logging — pass attributes to logger methods instead of string interpolation Next Steps Controllers Middleware Configuration --- # Sub-apps URL: https://justscale.sh/docs/fundamentals/sub-apps Sub-apps Isolated JustScale() scopes composed into a parent app with scope-switched bridges A sub-app is a separate JustScale() compilation unit with its own DI container, its own controllers, and its own AbstractContainer reflection. You compose it into a parent by adding it with .add(). The parent provides the services the sub-app declared it needs via .requires(); the framework bridges those services into the sub-app at compile time, preserving async scope so calls through them still execute in the parent's container. Sub-app vs Feature Features flatten into the parent scope — everything a feature provides lands directly in the parent's DI container. Sub-apps don't. A sub-app keeps its internals invisible to the parent; only what it expressly exposes through its own route handlers or (future) .exposes() reaches out. Feature — flat merge, shared scope. Use when everything the bundle provides is meant to be callable from anywhere in the parent app. Classic example: AuthFeature. Sub-app — isolated scope, explicit requires, scope-switched bridges. Use when internals genuinely don't belong in the parent's DI graph. Classic examples: admin subsurface, plugin packs, tenant-scoped sub-trees, docs-serving subsystems. Declaring a sub-app A sub-app is just a JustScale() that called .requires() at least once. Calling .requires(T) marks the builder as a sub-app and makes it un-compilable on its own — the type system gates .compile() with a branded error until the sub-app is composed into a parent that provides T. docs.sub-app.tsTypeScript ```typescript import JustScale, { Config } from '@justscale/core'; import { HttpConfig } from '@justscale/http'; import { OpenApiConfig, OpenApiFeature } from '@justscale/feature-openapi'; import { CatalogService } from '../domains/catalog/catalog.service.js'; import { InventoryService } from '../domains/catalog/inventory.service.js'; import { StockController } from './stock.controller.js'; // Sub-app: separate scope, mounts under /admin/* // .requires() lines are the explicit surface the parent must cover. // Parent's services are bridged in at compose time and the docs // routes merge into the shop's HTTP server via delegation. export const DocsSubApp = JustScale() .requires(CatalogService) .requires(InventoryService) .requires(Config.of(HttpConfig)) .add(StockController) .add(OpenApiFeature) .build(); ``` Composing into a parent The parent just .add()s the built sub-app. At build time the framework checks that the parent's TProvided covers every token the sub-app listed — missing requires are a compile error, pointing at the exact .add(SubApp) call. app.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { CatalogFeature } from './domains/catalog/catalog.feature'; import { DocsSubApp } from './docs/docs.sub-app'; JustScale() .add(CatalogFeature) // provides CatalogService, InventoryService .add(defaultHttpConfig) // provides Config.of(HttpConfig) .add(DocsSubApp); // TypeScript verifies the three requires are covered ``` Compile-time gate: if CatalogFeature wasn't added before DocsSubApp, TypeScript emits a MissingSubAppRequiresError naming the uncovered token — no runtime surprise. Runtime composition Three framework mechanisms connect sub-app and parent at runtime: Scope-switched bridges. Each token in the sub-app's .requires() is resolved against the parent's container, wrapped in aProxy, and registered in the sub-app's container. Method calls on the bridged service run inside runWithContainer(parentContainer, ...) — sogetContainer() inside a parent service reads the parent's scope, not the sub-app's. Route delegation. The parent app's match(method, path) tries its own controllers first, then falls through to each sub-app's match()recursively. Parent routes win on collision. The matched route carries its owning container as owningContainer; app.execute() uses that container forrunInFullRequestScope, so sub-app handlers read the right scope. Build-context inheritance. When a sub-app compiles, its adapter installs (e.g. the HTTP adapter triggered by a Get() route factory) forward to the parent's kernel. One HTTP server serves the whole composition tree — no per-sub-app listener. Per-scope reflection via AbstractContainer Every compiled scope binds its own AbstractContainer. Services in a sub-app that inject AbstractContainer see only that sub-app's controllers; parent'sAbstractContainer sees only parent's. This is how per-scope OpenAPI works — an OpenApiFeature added in the sub-app generates a spec for just that surface, with a different title and path than the root's spec. per-scope-openapi.tsTypeScript ```typescript // Root scope JustScale() .add(ShopFeature) .add(createConfig({ provides: [OpenApiConfig], factory: () => ({ [OpenApiConfig.key]: { info: { title: 'Shop API', version: '1.0.0' } }, }), })) .add(OpenApiFeature) // serves /openapi.json covering shop .add(DocsSubApp); // sub-app with its own OpenApiFeature... // Docs sub-app JustScale() .requires(CatalogService) .add(StockController) .add(createConfig({ provides: [OpenApiConfig], factory: () => ({ [OpenApiConfig.key]: { info: { title: 'Admin API', version: '1.0.0' }, specPath: '/admin/openapi.json', }, }), })) .add(OpenApiFeature) // ...serves /admin/openapi.json covering just admin .build(); ``` Two OpenAPI specs, one HTTP server, fully disjoint content — each reflects only its own scope's AbstractContainer. Multi-level nesting Sub-apps compose recursively: a sub-app's builder can itself .add(otherSubApp). Routes delegate through every level, the build context bubbles to the root, and TRequires check at each boundary — grandchild's requires must be covered by the child, which must be covered by the root. nesting.tsTypeScript ```typescript const Inner = JustScale() .requires(SharedService) .add(InnerController) .build(); const Middle = JustScale() .requires(SharedService) // middle must cover Inner's require .add(Inner) .build(); const root = JustScale() .add(SharedService) // root covers middle's require .add(Middle) .build(); // Routes from Inner reachable via root.match() ``` When to reach for a sub-app Admin subsurface. Admin-only services, guards, rate limits that you want invisible to the customer-facing shop scope. Plugin packs. Each plugin ships as a sub-app with its own internals; only the .requires() surface touches the host. Tenant scopes. Per-tenant container with tenant-specific config bridged in from a tenant-aware parent. Documentation / OpenAPI surfaces. A separate scope emits its own spec reflecting just its routes, without leaking the rest of the app. What sub-apps are not Not a separate process. Sub-apps run in the same Node process as their parent. For process isolation, look at the cluster transport (distributed roadmap). Not a replacement for Features. If everything a bundle provides is meant to be available to the rest of the app, it's a Feature, not a sub-app. Sub-apps are about keeping internals private. Not free. Each bridged service method call pays a Proxy hop and an async-context switch. Fine for RPC-style boundaries; worth measuring before putting something hot behind a bridge. Next Steps Features Controllers OpenAPI --- # Path Parameters URL: https://justscale.sh/docs/http/path-parameters Path Parameters Extract typed parameters from URL paths Path parameters allow you to capture dynamic segments from URLs. JustScale provides compile-time type safety for path parameters extracted from route patterns. Basic Path Parameters Define path parameters using the :paramName syntax: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const UsersController = createController('/users', { routes: () => ({ getOne: Get('/:id').handle(({ params, res }) => { // params.id is typed as string and guaranteed to exist const userId = params.id; res.json({ userId }); }), }), }); ``` When a request comes in to /users/123, the params object will be { id: '123' }. Type Safety TypeScript extracts the parameter names from your path pattern and makes them available in the params object with type safety: src/controllers/posts.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const PostsController = createController('/posts', { routes: () => ({ getComment: Get('/:postId/comments/:commentId').handle(({ params, res }) => { // TypeScript knows params has both postId and commentId const { postId, commentId } = params; // This would be a TypeScript error: // const invalid = params.nonexistent; // Error! res.json({ postId, commentId }); }), }), }); ``` Multiple Path Parameters You can have multiple parameters in a single route: src/controllers/orgs.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const OrgsController = createController('/orgs', { routes: () => ({ getMember: Get('/:orgId/teams/:teamId/members/:userId').handle(({ params, res }) => { const { orgId, teamId, userId } = params; res.json({ orgId, teamId, userId }); }), }), }); ``` Example request: Bash ```bash curl http://localhost:3000/orgs/acme/teams/engineering/members/alice # { "orgId": "acme", "teamId": "engineering", "userId": "alice" } ``` Parameter Constraints All Parameters Are Strings Path parameters are always extracted as strings. If you need a number, parse it: src/controllers/items.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const ItemsController = createController('/items', { routes: () => ({ getOne: Get('/:itemId').handle(({ params, res }) => { const itemId = parseInt(params.itemId, 10); if (isNaN(itemId)) { res.error('Invalid item ID', 400); return; } res.json({ itemId }); }), }), }); ``` Using populate() for Type Conversion For entities, use the populate middleware with a transformfunction to convert the ID: src/controllers/posts.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { populate } from '@justscale/http'; import { PostService } from '../services/post-service'; export const PostsController = createController('/posts', { inject: { posts: PostService }, routes: (services) => ({ getOne: Get('/:postId') .use(populate( services.posts, 'post', 'postId', Post.ref, { transform: id => parseInt(id, 10) } )) .handle(({ post, res }) => { // post is already fetched with numeric ID res.json({ post }); }), }), }); ``` URL Encoding Parameters are automatically URL-decoded: src/controllers/search.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const SearchController = createController('/search', { routes: () => ({ search: Get('/:query').handle(({ params, res }) => { // Request: /search/hello%20world console.log(params.query); // "hello world" (decoded) res.json({ query: params.query }); }), }), }); ``` Validation While path parameters are type-safe at compile time, you should validate them at runtime: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { validateParams } from '../middleware/validate-params'; import { ParamsSchema } from '../schemas/params'; export const UsersController = createController('/users', { routes: () => ({ getOne: Get('/:id') .use(validateParams(ParamsSchema)) .handle(({ params, res }) => { // params.id is guaranteed to be a UUID string res.json({ userId: params.id }); }), }), }); ``` Common Patterns UUID Parameters src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const UsersController = createController('/users', { routes: () => ({ getOne: Get('/:userId').handle(({ params, res }) => { // Validate UUID format const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!uuidRegex.test(params.userId)) { res.error('Invalid user ID format', 400); return; } res.json({ userId: params.userId }); }), }), }); ``` Slug Parameters src/controllers/blog.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const BlogController = createController('/blog', { routes: () => ({ getPost: Get('/:slug').handle(({ params, res }) => { const slug = params.slug; // slug might be: "my-first-post" or "hello-world" res.json({ slug }); }), }), }); ``` Nested Resources src/controllers/posts.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, Delete } from '@justscale/http'; import { PostService } from '../services/post-service'; export const PostsController = createController('/posts', { inject: { posts: PostService }, routes: (services) => ({ // GET /posts/:postId getOne: Get('/:postId').handle(({ params, res }) => { res.json({ postId: params.postId }); }), // GET /posts/:postId/comments/:commentId getComment: Get('/:postId/comments/:commentId').handle(({ params, res }) => { res.json({ postId: params.postId, commentId: params.commentId }); }), }), }); ``` Non-Matching Routes ℹ️Info If a request doesn't match any route pattern, JustScale returns a 404 automatically. Bash ```bash # Route defined: GET /users/:id curl http://localhost:3000/users/123 # Matches curl http://localhost:3000/users # 404 Not Found curl http://localhost:3000/users/123/foo # 404 Not Found ``` Next Steps Query & Body Response Types Validation --- # Query Parameters & Request Body URL: https://justscale.sh/docs/http/query-body Query Parameters & Request Body Parse and validate query strings and request bodies with Zod schemas JustScale provides query() and body() plugins for validating query parameters and request bodies using Zod schemas. Query Parameters Accessing Raw Query Parameters Query parameters are available as strings in the query object: src/controllers/search.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const SearchController = createController('/search', { routes: () => ({ search: Get('/').handle(({ query, res }) => { // Request: /search?q=hello&limit=10 console.log(query.q); // "hello" console.log(query.limit); // "10" (string!) res.json({ query }); }), }), }); ``` ⚠️Warning Query parameters are always strings. Use query() for type conversion and validation. Using query() Plugin The query() plugin validates and transforms query parameters using a Zod schema: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, query } from '@justscale/http/builder'; import { PaginationSchema } from '../schemas/pagination'; export const UsersController = createController('/users', { routes: () => ({ list: Get('/') .query(PaginationSchema) .handle(({ query, res }) => { // query is now typed as { page: number; limit: number } const { page, limit } = query; res.json({ page, limit }); }), }), }); ``` Example requests: Bash ```bash # With query params curl "http://localhost:3000/users?page=2&limit=20" # { "page": 2, "limit": 20 } # Using defaults curl "http://localhost:3000/users" # { "page": 1, "limit": 10 } # Invalid values curl "http://localhost:3000/users?page=-1" # { "error": "Validation error message" } - 400 Bad Request ``` Query Validation Patterns Search & Filtering src/controllers/products.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, query } from '@justscale/http/builder'; import { SearchSchema } from '../schemas/search'; export const ProductsController = createController('/products', { routes: () => ({ list: Get('/') .query(SearchSchema) .handle(({ query, res }) => { // query.q: string | undefined // query.status: 'active' | 'inactive' | 'all' // query.sort: 'name' | 'created' | 'updated' res.json({ query }); }), }), }); ``` Boolean Flags src/controllers/items.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, query } from '@justscale/http/builder'; import { FlagsSchema } from '../schemas/flags'; export const ItemsController = createController('/items', { routes: () => ({ list: Get('/') .query(FlagsSchema) .handle(({ query, res }) => { // ?includeDeleted=true → query.includeDeleted = true // ?includeDeleted=false → query.includeDeleted = false // (no param) → query.includeDeleted = false (default) res.json({ includeDeleted: query.includeDeleted }); }), }), }); ``` Date Ranges src/controllers/reports.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, query } from '@justscale/http/builder'; import { DateRangeSchema } from '../schemas/date-range'; export const ReportsController = createController('/reports', { routes: () => ({ list: Get('/') .query(DateRangeSchema) .handle(({ query, res }) => { // ?startDate=2024-01-01&endDate=2024-12-31 // query.startDate: Date | undefined // query.endDate: Date | undefined res.json({ start: query.startDate?.toISOString(), end: query.endDate?.toISOString(), }); }), }), }); ``` Request Body Accessing Raw Body The request body is automatically parsed as JSON and available in the body property: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; export const UsersController = createController('/users', { routes: () => ({ create: Post('/').handle(({ body, res }) => { // body is typed as 'unknown' console.log(body); // { name: "Alice", email: "alice@example.com" } res.json({ received: body }); }), }), }); ``` ⚠️Warning Raw rawBody is typed as unknown. Always validate it before use! Using body() Plugin The body() plugin validates the request body against a Zod schema and exposes it as typed body in the context: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post, body } from '@justscale/http/builder'; import { UserService } from '../services/user-service'; import { CreateUserBody, UserResponse, ErrorResponse } from '../schemas/user'; export const UsersController = createController('/users', { inject: { users: UserService }, routes: (services) => ({ create: Post('/') .body(CreateUserBody) .returns(201, UserResponse) .returns(400, ErrorResponse) // Validation errors .handle(async ({ body, res }) => { // body is typed as { name: string; email: string; age?: number } const user = await services.users.create(body); res.status(201).json({ user }); }), }), }); ``` Example requests: Bash ```bash # Valid request curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name":"Alice","email":"alice@example.com","age":25}' # 201 Created # Invalid email curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name":"Alice","email":"invalid"}' # { "error": "Invalid email" } - 400 Bad Request # Missing required field curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"email":"alice@example.com"}' # { "error": "Name is required" } - 400 Bad Request ``` Validation Patterns Nested Objects src/controllers/posts.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post, body } from '@justscale/http/builder'; import { CreatePostSchema } from '../schemas/post'; export const PostsController = createController('/posts', { routes: () => ({ create: Post('/') .body(CreatePostSchema) .handle(({ body, res }) => { // body.author.name, body.author.email, body.tags are all typed res.status(201).json({ post: body }); }), }), }); ``` Optional Fields with Defaults src/controllers/players.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post, body } from '@justscale/http/builder'; import { CreatePlayerSchema } from '../schemas/player'; export const PlayersController = createController('/players', { routes: () => ({ create: Post('/') .body(CreatePlayerSchema) .handle(({ body, res }) => { // If chips is not provided, defaults to 1000 // If isActive is not provided, defaults to true res.status(201).json({ player: body }); }), }), }); ``` Union Types src/controllers/profile.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Put, body } from '@justscale/http/builder'; import { UpdateUserSchema } from '../schemas/profile'; export const ProfileController = createController('/profile', { routes: () => ({ update: Put('/') .body(UpdateUserSchema) .handle(({ body, res }) => { // TypeScript knows body.type is either 'email' or 'password' if (body.type === 'email') { // body.email is available } else { // body.currentPassword and body.newPassword are available } res.json({ updated: true }); }), }), }); ``` Combining Query and Body You can use both query() and body() in the same route: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post, query, body } from '@justscale/http/builder'; import { UserService, sendWelcomeEmail } from '../services/user-service'; import { QuerySchema, CreateUserBody, UserResponse, ErrorResponse } from '../schemas/user'; export const UsersController = createController('/users', { inject: { users: UserService }, routes: (services) => ({ create: Post('/') .query(QuerySchema) .body(CreateUserBody) .returns(201, UserResponse) .returns(400, ErrorResponse) .handle(async ({ query, body, res }) => { // query.notify: boolean // body.name: string // body.email: string const user = await services.users.create(body); if (query.notify) { sendWelcomeEmail(user.email); } res.status(201).json({ user }); }), }), }); ``` Error Handling When validation fails, query() or body() automatically: Returns a 400 Bad Request status with field-level errors Stops execution before reaching the handler ℹ️Info The validation error messages come directly from Zod. Customize them using Zod's error message options. src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post, body } from '@justscale/http/builder'; import { UserSchema } from '../schemas/user'; export const UsersController = createController('/users', { routes: () => ({ create: Post('/') .body(UserSchema) .handle(({ body, res }) => { // This handler only runs if validation succeeds res.status(201).json({ user: body }); }), }), }); ``` Next Steps Response Types Validation Middleware --- # Request Handling URL: https://justscale.sh/docs/http/request-handling Request Handling Work with HTTP requests and responses in JustScale HTTP routes receive a context object containing request data (params, body,query, headers) and a response helper (res). The Request Context Every HTTP route handler receives a context object with transport-specific properties: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const UsersController = createController('/users', { routes: () => ({ getOne: Get('/:id').handle(({ params, body, query, headers, res }) => { // params: Route parameters extracted from the URL path // body: Parsed JSON request body (POST/PUT/PATCH) // query: Query string parameters // headers: Request headers // res: Response helper for sending responses }), }), }); ``` The Response Object The res object provides methods for sending HTTP responses. Always pair with .returns() to declare response types: res.json(data) Send a JSON response with 200 status: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { UsersResponse } from '../schemas/user'; export const UsersController = createController('/users', { routes: () => ({ list: Get('/') .returns(200, UsersResponse) .handle(({ res }) => { res.json({ users: [{ name: 'Alice' }] }); // HTTP 200 with Content-Type: application/json }), }), }); ``` res.status(code).json(data) Send a JSON response with a custom status code: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; import { body } from '@justscale/http/builder'; import { UserService } from '../services/user-service'; import { UserResponse, ErrorResponse, CreateUserBody } from '../schemas/user'; export const UsersController = createController('/users', { inject: { users: UserService }, routes: (services) => ({ create: Post('/') .body(CreateUserBody) .returns(201, UserResponse) .returns(400, ErrorResponse) .handle(async ({ body, res }) => { const user = await services.users.create(body); res.status(201).json({ user }); // HTTP 201 Created }), }), }); ``` res.status(code).end() Send an empty response (for void responses like 204): src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Delete } from '@justscale/http'; import { User } from '../models'; import { UserService } from '../services/user-service'; export const UsersController = createController('/users', { inject: { users: UserService }, routes: ({ users }) => ({ delete: Delete('/:user') .types({ User }) .returns(204) .returns(404) .handle(async ({ params, res }) => { const user = await params.user; if (!user) return res.status(404).end(); await users.delete(user); res.status(204).end(); // HTTP 204 No Content }), }), }); ``` res.error(message, status?) Send an error response (default 400): src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { UserResponse, ErrorResponse } from '../schemas/user'; export const UsersController = createController('/users', { routes: () => ({ getOne: Get('/:id') .returns(200, UserResponse) .returns(400, ErrorResponse) .returns(404) .handle(({ params, res }) => { if (!params.id) { return res.status(400).json({ error: 'User ID is required' }); } // Continue processing... }), }), }); ``` Headers Access request headers through the headers object: src/controllers/profile.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const ProfileController = createController('/profile', { routes: () => ({ getProfile: Get('/').handle(({ headers, res }) => { const authHeader = headers.authorization; const userAgent = headers['user-agent']; if (!authHeader) { res.error('Authorization header required', 401); return; } res.json({ authenticated: true }); }), }), }); ``` ℹ️Info Header names are lowercase in the headers object. Cookies Cookies are available in the headers.cookie string. You can parse them manually or use a middleware: src/controllers/session.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const SessionController = createController('/', { routes: () => ({ checkSession: Get('/check-session').handle(({ headers, res }) => { const cookieHeader = headers.cookie; // cookieHeader = "session=abc123; theme=dark" // Parse cookies manually const cookies = Object.fromEntries( cookieHeader?.split('; ').map(c => c.split('=')) ?? [] ); res.json({ sessionId: cookies.session }); }), }), }); ``` Auto-Response Handling If your handler doesn't send a response, JustScale automatically sends a 204 No Content: src/controllers/health.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const HealthController = createController('/', { routes: () => ({ ping: Get('/ping').handle(() => { // No response sent - JustScale sends 204 No Content }), }), }); ``` ⚠️Warning Always ensure you send a response or throw an error. Silent failures can be confusing! Error Handling If a handler throws an error, JustScale catches it and sends a 500 response: src/controllers/risky.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { DataResponse } from '../schemas/data'; export const RiskyController = createController('/', { routes: () => ({ risky: Get('/risky') .returns(200, DataResponse) .returns(500) // Document that this can fail .handle(({ res }) => { throw new Error('Something went wrong!'); // JustScale sends: {"error": "Something went wrong!"} with status 500 }), }), }); ``` For controlled error responses, declare them with .returns(): src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { User } from '../models'; import { UserService } from '../services/user-service'; import { UserResponse, ErrorResponse } from '../schemas/user'; export const UsersController = createController('/users', { inject: { users: UserService }, routes: ({ users }) => ({ // .types({ User }) makes params.user a Ref. Awaiting resolves it. getOne: Get('/:user') .types({ User }) .returns(200, UserResponse) .returns(404) .handle(async ({ params, res }) => { const user = await params.user; if (!user) return res.status(404).end(); res.json({ user }); }), // With a custom error body getOneManual: Get('/:user/details') .types({ User }) .returns(200, UserResponse) .returns(404, ErrorResponse) .handle(async ({ params, res }) => { const user = await params.user; if (!user) { return res.status(404).json({ error: 'User not found' }); } res.json({ user }); }), }), }); ``` Multiple Responses ⚠️Warning Only the first response is sent. Subsequent calls to res.json() orres.error() are ignored: src/controllers/example.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const ExampleController = createController('/', { routes: () => ({ example: Get('/example').handle(({ res }) => { res.json({ message: 'First' }); res.json({ message: 'Second' }); // Ignored - response already sent }), }), }); ``` Next Steps Path Parameters Query & Body Response Types --- # Response Types URL: https://justscale.sh/docs/http/response-types Response Types Send JSON, HTML, streams, and custom responses with typed status codes JustScale's HTTP transport provides a flexible response API with type safety for different content types and status codes. JSON Responses res.json(data) The most common response method sends JSON with a 200 OK status: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const UsersController = createController('/users', { routes: () => ({ list: Get('/').handle(({ res }) => { res.json({ users: [ { name: 'Alice' }, { name: 'Bob' }, ], }); // HTTP 200 OK // Content-Type: application/json }), }), }); ``` Custom Status Codes Use res.status(code).json(data) to send JSON with a specific status code: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; function createUser(body: any) { return { name: body.name }; } export const UsersController = createController('/users', { routes: () => ({ // In a controller's routes create: Post('/').handle(({ body, res }) => { const user = createUser(body); res.status(201).json({ user }); // HTTP 201 Created }), }), }); ``` Common status codes: 200 - OK (default for res.json) 201 - Created (after successful POST) 202 - Accepted (async processing) 204 - No Content (successful DELETE) Empty Responses res.status(code).end() Send an empty response with just a status code (useful for 204, 403, 409, etc.): src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Delete } from '@justscale/http'; function deleteUser(id: string) { console.log('Deleting user', id); } export const UsersController = createController('/users', { routes: () => ({ // In a controller's routes remove: Delete('/:id').handle(({ params, res }) => { deleteUser(params.id); res.status(204).end(); // HTTP 204 No Content (no body) }), }), }); ``` src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; function checkUserExists(email: string) { return email === 'exists@example.com'; } export const UsersController = createController('/users', { routes: () => ({ // In a controller's routes create: Post('/').handle(({ body, res }) => { const exists = checkUserExists(body.email); if (exists) { res.status(409).end(); // HTTP 409 Conflict (no body needed) return; } // Create user... }), }), }); ``` Error Responses res.error(message, status?) Send error responses with a message and optional status code (defaults to 400): src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { UserService } from '../services/user-service'; export const UsersController = createController('/users', { inject: { users: UserService }, routes: (services) => ({ getOne: Get('/:id').handle(({ params, res }) => { const user = services.users.get(params.id); if (!user) { res.error('User not found', 404); // HTTP 404: { "error": "User not found" } return; } res.json({ user }); }), }), }); ``` Common error status codes: 400 - Bad Request (validation error) 401 - Unauthorized (missing auth) 403 - Forbidden (insufficient permissions) 404 - Not Found (resource doesn't exist) 409 - Conflict (duplicate resource) 422 - Unprocessable Entity (semantic error) 500 - Internal Server Error Typed Response Schemas Use the .returns() method to declare response types. Once declared,res.status().json() and res.status().end() are type-checked against your declared schemas: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, Post, Delete } from '@justscale/http'; import { User } from '../models'; import { UserService } from '../services/user-service'; import { UserResponse, ErrorResponse, CreateUserBody } from '../schemas/user'; export const UsersController = createController('/users', { inject: { users: UserService }, routes: ({ users }) => ({ // :user matches User in .types() → params.user is Ref. // Awaiting the ref resolves it via the repository. getOne: Get('/:user') .types({ User }) .returns(200, UserResponse) .returns(404) .handle(async ({ params, res }) => { const user = await params.user; if (!user) return res.status(404).end(); res.json({ user }); // TypeScript: { user: UserResponse } // res.json({ wrong: 'shape' }); // TypeScript ERROR! }), // res.status(201).json() typed to UserResponse // res.status(400).json() typed to ErrorResponse create: Post('/') .body(CreateUserBody) .returns(201, UserResponse) .returns(400, ErrorResponse) .handle(async ({ body, res }) => { const existing = await users.findByEmail(body.email); if (existing) { // TypeScript knows this must be ErrorResponse shape return res.status(400).json({ error: 'Email taken' }); } const user = await users.create(body); // TypeScript knows this must be UserResponse shape res.status(201).json({ user }); }), // res.status(204).end() - no body required remove: Delete('/:user') .types({ User }) .returns(204) .returns(404) .handle(async ({ params, res }) => { const user = await params.user; if (!user) return res.status(404).end(); await users.delete(user); res.status(204).end(); // No body needed for 204 }), }), }); ``` Type Safety Benefits res.json(data) — TypeScript validates data matches the 200 response schema res.status(201).json(data) — TypeScript validates data matches the 201 response schema res.status(400).json(data) — TypeScript validates data matches the 400 error schema res.status(204).end() — Only allowed when 204 is declared (no body) Undeclared status codes cause TypeScript errors at compile time ℹ️Info The .returns() method provides compile-time type checking and enables automatic OpenAPI schema generation . Multiple Response Types For routes that can return different status codes, you can chain multiple .returns() calls: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { UserResponse, ErrorResponse } from '../schemas/user'; function findUser(id: string) { return id === '1' ? { name: 'Alice' } : null; } export const UsersController = createController('/users', { routes: () => ({ getOne: Get('/:id') .returns(200, UserResponse) // 200 response .returns(404, ErrorResponse) // 404 response .handle(({ params, res }) => { const user = findUser(params.id); if (!user) { res.status(404).json({ error: 'User not found' }); return; } res.json({ user }); }), }), }); ``` HTML Responses While JustScale is primarily designed for JSON APIs, you can send HTML by manually setting headers: src/controllers/home.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; // Note: JustScale is designed for JSON APIs // For HTML, you'd extend the res object or use custom middleware export const HomeController = createController('/', { routes: () => ({ home: Get('/').handle(({ res }) => { res.json({ message: 'Use res.json() for JSON APIs' }); }), }), }); ``` ⚠️Warning JustScale is optimized for JSON APIs. For HTML rendering, consider using a dedicated frontend framework or template engine alongside your API. Streaming Responses The current HTTP server implementation doesn't directly expose streaming APIs, but you can work around it by accessing the raw response object if needed. ℹ️Info For streaming use cases (Server-Sent Events, file downloads, etc.), you may need to extend the HTTP server or use a custom transport. JustScale's transport-agnostic design makes this possible. Response Headers The HTTP server automatically sets common headers: Content-Type: application/json for JSON responses Access-Control-Allow-Origin: * for CORS To set custom headers, you'll need to extend the response object or use middleware. Auto-204 Behavior If your handler doesn't send any response, JustScale automatically sends a 204 No Content: src/controllers/webhooks.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; function processWebhook(body: any) { console.log('Processing webhook:', body); } export const WebhooksController = createController('/webhooks', { routes: () => ({ github: Post('/github').handle(({ body }) => { // Process webhook processWebhook(body); // No response sent - JustScale sends 204 No Content }), }), }); ``` ℹ️Info This is useful for webhooks and fire-and-forget endpoints where you don't need to send a response body. Response Patterns RESTful CRUD src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, Post, Put, Delete } from '@justscale/http'; import { UserService } from '../services/user-service'; export const UsersController = createController('/users', { inject: { users: UserService }, routes: (services) => ({ // List: 200 with array list: Get('/').handle(({ res }) => { res.json({ users: services.users.findAll() }); }), // Get One: 200 or 404 getOne: Get('/:id').handle(({ params, res }) => { const user = services.users.get(params.id); if (!user) { res.error('User not found', 404); return; } res.json({ user }); }), // Create: 201 with created resource create: Post('/').handle(({ body, res }) => { const user = services.users.create(body); res.status(201).json({ user }); }), // Update: 200 with updated resource update: Put('/:id').handle(({ params, body, res }) => { const user = services.users.update(params.id, body); res.json({ user }); }), // Delete: 204 (no content) remove: Delete('/:id').handle(({ params, res }) => { services.users.delete(params.id); res.status(204).end(); }), }), }); ``` Conditional Responses src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; const users = { findByEmail: (email: string) => email === 'exists@example.com' ? { email } : null, create: (body: any) => ({ ...body }), }; export const UsersController = createController('/users', { routes: () => ({ // In a controller's routes create: Post('/').handle(({ body, res }) => { const exists = users.findByEmail(body.email); if (exists) { res.status(409).json({ error: 'User already exists', }); return; } const user = users.create(body); res.status(201).json({ user }); }), }), }); ``` Partial Success src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; const users = { create: (userData: any) => ({ ...userData }), }; export const UsersController = createController('/users', { routes: () => ({ // In a controller's routes bulkCreate: Post('/bulk').handle(({ body, res }) => { const results = body.users.map((userData: any) => { try { return { success: true, user: users.create(userData) }; } catch (err) { return { success: false, error: (err as Error).message }; } }); const hasFailures = results.some(r => !r.success); res.status(hasFailures ? 207 : 201).json({ results }); // 207 Multi-Status for partial success }), }), }); ``` Next Steps Validation OpenAPI Error Handling --- # Server-Sent Events URL: https://justscale.sh/docs/http/sse Server-Sent Events Real-time streaming with SSE routes The @justscale/sse package provides an SSE() route factory for streaming events to HTTP clients. SSE routes use the same fluent builder API as HTTP routes — .use(),.guard(), .types() — but the handler is an async generator that yields events. Installation Bash ```bash npm install @justscale/sse ``` Then import the package in your app entry point to register the SSE request handler: TypeScript ```typescript import '@justscale/sse'; ``` Basic SSE Route Create an SSE endpoint with the SSE() factory. The handler is an async generator that yields SSEEvent objects: notifications-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { SSE } from '@justscale/sse'; import { NotificationService } from './notification-service'; export const NotificationController = createController('/notifications', { inject: { notifications: NotificationService }, routes: ({ notifications }) => ({ stream: SSE('/stream') .handle(async function* () { yield { event: 'connected', data: { status: 'ok' } }; for await (const notification of notifications.subscribe()) { yield { event: 'notification', data: notification }; } }), }), }); ``` SSE Event Format Each yielded object maps to SSE fields: events.tsTypeScript ```typescript // All fields except 'data' are optional yield { event: 'update', // event: update id: '42', // id: 42 data: { count: 1 }, // data: {"count":1} retry: 5000, // retry: 5000 }; // Minimal — just data yield { data: 'hello' }; // data: hello ``` Path Parameters SSE routes infer path parameters from the path string, just like HTTP routes: room-controller.tsTypeScript ```typescript import { SSE } from '@justscale/sse'; // params.roomId is typed as 'string' — inferred from the path SSE('/:roomId/events') .handle(async function* ({ params }) { yield { data: { room: params.roomId } }; // ...stream room events }); ``` Typed Parameters with .types() Use .types() to transform path parameters from strings into model references. This is the same API as HTTP routes: ticket-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { ModelRepository } from '@justscale/core/models'; import { SSE } from '@justscale/sse'; import { Ticket } from './domain'; export const TicketController = createController('/tickets', { inject: { tickets: ModelRepository.of(Ticket) }, routes: ({ tickets }) => ({ events: SSE('/:ticket/events') .types({ Ticket }) .handle(async function* ({ params }) { // params.ticket is Reference — await to resolve const ticket = await params.ticket; if (!ticket) { yield { event: 'error', data: { message: 'Not found' } }; return; } yield { event: 'connected', data: { subject: ticket.subject } }; for await (const event of ticket.events) { yield { event: event.type, data: event.data }; } }), }), }); ``` ℹ️Info The .types() method uses the same matching rules as HTTP routes:types: { Ticket } matches :ticket, :Ticket, and :ticketRef. Middleware and Guards SSE routes support the same .use() and .guard() chain as HTTP routes. Middleware context accumulates and is available in the generator: protected-stream.tsTypeScript ```typescript import { SSE } from '@justscale/sse'; import { auth } from '@justscale/auth'; SSE('/dashboard/events') .use(auth) .guard(({ user }) => user.role === 'admin') .handle(async function* ({ user }) { yield { event: 'welcome', data: { name: user.name } }; // Stream admin dashboard updates... }); ``` Client Disconnect When the client disconnects, the generator is automatically cleaned up. Use try/finally to release resources: cleanup.tsTypeScript ```typescript SSE('/events') .handle(async function* ({ aborted }) { const subscription = eventBus.subscribe(); try { for await (const event of subscription) { yield { event: event.type, data: event.payload }; } } finally { // Runs when client disconnects subscription.unsubscribe(); } }); ``` The aborted promise resolves when the client disconnects, useful for cancelling other async operations. Combining with HTTP Routes SSE routes live alongside regular HTTP routes in the same controller: orders-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, Post } from '@justscale/http'; import { SSE } from '@justscale/sse'; import { Order, OrderService } from './domain'; export const OrderController = createController('/orders', { inject: { orders: OrderService }, routes: ({ orders }) => ({ // Regular HTTP routes list: Get('/').handle(async ({ res }) => { res.json(await orders.list()); }), // SSE route for real-time updates events: SSE('/:order/events') .types({ Order }) .handle(async function* ({ params }) { const order = await params.order; if (!order) return; for await (const update of order.events) { yield { event: update.type, data: update.data }; } }), }), }); ``` Next Steps Routes Typed Parameters Middleware --- # Typed Parameters URL: https://justscale.sh/docs/http/typed-params Typed Parameters Transform path parameters into model references with .types() Path parameters are strings by default. The .types() method transforms them into model references — awaitable Reference objects that resolve to the actual entity from the database. Basic Usage Pass a map of model classes to .types(). Matching path parameters become references automatically: src/controllers/ticket-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get, Post } from '@justscale/http'; import { auth } from '@justscale/auth'; import { Ticket } from '../domain/ticket'; import { HelpdeskService } from '../application/helpdesk-service'; export const TicketController = createController('/tickets', { inject: { helpdesk: HelpdeskService }, routes: ({ helpdesk }) => ({ get: Get('/:ticket') .types({ Ticket }) .use(auth) .handle(async ({ params, res }) => { // params.ticket is Reference — await to resolve const ticket = await params.ticket; if (!ticket) return res.status(404).json({ error: 'Not found' }); res.json(ticket); }), resolve: Post('/:ticket/resolve') .types({ Ticket }) .use(auth) .guard(Ticket.can.resolve) .handle(async ({ params, res, user }) => { const ticket = await params.ticket; if (!ticket) return res.status(404).json({ error: 'Not found' }); await helpdesk.resolveTicket(ticket, user.name ?? user.email); res.status(204).end(); }), }), }); ``` Matching Rules .types() matches parameter names using three strategies: Direct match — types: { product: Product } matches :product Lowercased class name — types: { Product } matches :product Lowercased + "Ref" suffix — types: { Product } matches :productRef matching-examples.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { Product } from './domain'; // All three are equivalent: Get('/:product').types({ Product }) // matches :product Get('/:product').types({ product: Product }) // matches :product Get('/:productRef').types({ Product }) // matches :productRef ``` Multiple Typed Parameters Pass multiple models to type several parameters at once. Unmatched parameters stay as strings: order-controller.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { Order, Product } from './domain'; Get('/:order/items/:product/reviews/:reviewId') .types({ Order, Product }) .handle(async ({ params }) => { // params.order → Reference // params.product → Reference // params.reviewId → string (no matching model) const order = await params.order; const product = await params.product; const reviewId = params.reviewId; }); ``` Works with SSE Routes The .types() method is shared between HTTP and SSE routes: ticket-events.tsTypeScript ```typescript import { SSE } from '@justscale/sse'; import { Ticket } from './domain'; SSE('/:ticket/events') .types({ Ticket }) .handle(async function* ({ params }) { const ticket = await params.ticket; // Reference if (!ticket) return; yield { event: 'connected', data: { subject: ticket.subject } }; for await (const event of ticket.events) { yield { event: event.type, data: event.data }; } }); ``` How It Works When a request arrives, the framework: Extracts the raw string parameter from the URL (e.g. "prod-123") Creates a fresh Reference for the matching model Attaches the database resolver so await fetches from the database Passes the typed params to your handler ℹ️Info Each request gets a fresh Reference — there is no cross-request caching. Awaiting the same reference twice in one handler returns the cached result. Next Steps Path Parameters Server-Sent Events Guards --- # Field Builders URL: https://justscale.sh/docs/models/field-builders Field Builders Comprehensive reference for all field types and modifiers Field builders provide a type-safe, fluent API for defining model field types and constraints. Each builder corresponds to a PostgreSQL data type and provides relevant modifiers. String and Text field.string() Variable-length string field, maps to VARCHAR in PostgreSQL. Use .max(n) to set maximum length or .fixed(n) for fixed-length CHAR. string-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class User extends defineModel({ email: field.string().max(255).unique(), username: field.string().max(50).index(), countryCode: field.string().fixed(2), // CHAR(2) bio: field.string().optional(), // No max length }) {} ``` field.text() Unlimited-length text field for long content, maps to TEXT. text-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class BlogPost extends defineModel({ title: field.string().max(200), content: field.text(), // Long content summary: field.text().optional(), }) {} ``` Numbers field.int() Standard integer field, maps to INTEGER (32-bit signed, range -2,147,483,648 to 2,147,483,647). int-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Product extends defineModel({ quantity: field.int().default(0), viewCount: field.int().default(0), priority: field.int().optional(), }) {} ``` field.smallint() Small integer field, maps to SMALLINT (16-bit signed, range -32,768 to 32,767). Use for smaller numbers to save space. smallint-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Rating extends defineModel({ score: field.smallint(), // 1-5 rating stars: field.smallint(), // 1-10 stars }) {} ``` field.bigint() Large integer field, maps to BIGINT (64-bit signed). TypeScript type is bigint. bigint-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Analytics extends defineModel({ totalViews: field.bigint().default(0n), uniqueVisitors: field.bigint().default(0n), }) {} const stats = new Analytics({ totalViews: 9007199254740991n, // Beyond Number.MAX_SAFE_INTEGER }); ``` field.decimal(precision, scale) Fixed-precision decimal, maps to NUMERIC(precision, scale). Stored as stringin TypeScript to preserve exact precision. Essential for monetary values. decimal-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Order extends defineModel({ subtotal: field.decimal(10, 2), // Max: 99,999,999.99 tax: field.decimal(10, 2), total: field.decimal(10, 2), discountPercent: field.decimal(5, 2).optional(), // Max: 999.99 }) {} const order = new Order({ subtotal: '99.99', tax: '8.50', total: '108.49', }); ``` field.float() / field.double() Floating-point numbers. float() maps to REAL (32-bit),double() maps to DOUBLE PRECISION (64-bit). Avoid for money. float-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Measurement extends defineModel({ temperature: field.float(), latitude: field.double(), longitude: field.double(), precision: field.double().optional(), }) {} ``` Boolean field.boolean() Boolean field, maps to BOOLEAN. Often paired with .default(). boolean-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class User extends defineModel({ isActive: field.boolean().default(true), emailVerified: field.boolean().default(false), isPremium: field.boolean().optional(), }) {} ``` UUID field.uuid() UUID field, maps to UUID. TypeScript type is string. Often used for external IDs or references. uuid-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class ApiKey extends defineModel({ keyId: field.uuid().unique(), secret: field.string().max(255), userId: field.uuid().index(), }) {} ``` Date and Time field.timestamp() Timestamp with timezone, maps to TIMESTAMP WITH TIME ZONE. TypeScript type is Date. timestamp-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Event extends defineModel({ name: field.string(), scheduledAt: field.timestamp(), cancelledAt: field.timestamp().optional(), }) {} ``` field.date() Date only (no time), maps to DATE. TypeScript type is Date. date-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Person extends defineModel({ name: field.string(), birthDate: field.date(), hireDate: field.date().optional(), }) {} ``` field.time() Time of day (no date), maps to TIME. TypeScript type is string (HH:MM:SS format). time-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Schedule extends defineModel({ name: field.string(), startTime: field.time(), endTime: field.time(), }) {} const shift = new Schedule({ name: 'Morning Shift', startTime: '09:00:00', endTime: '17:00:00', }); ``` JSON field.json() / field.jsonb() JSON fields for structured data. json() maps to JSON,jsonb() maps to JSONB (binary, indexable). Provide a TypeScript type parameter for type safety. json-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; interface ProductMetadata { dimensions: { width: number; height: number; depth: number }; tags: string[]; features: string[]; } class Product extends defineModel({ name: field.string(), // JSONB for indexable structured data metadata: field.jsonb(), // JSON for simple unstructured data settings: field.json>().optional(), }) {} const product = new Product({ name: 'Laptop', metadata: { dimensions: { width: 14, height: 10, depth: 1 }, tags: ['electronics', 'computer'], features: ['Backlit keyboard', 'Touch screen'], }, }); ``` Binary field.bytes() Binary data field, maps to BYTEA. TypeScript type is Uint8Array. bytes-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class File extends defineModel({ filename: field.string().max(255), mimeType: field.string().max(100), content: field.bytes(), }) {} ``` Enums field.enum(name, values) PostgreSQL enum field. Provide enum name and array of string values. TypeScript infers a union type from the values. enum-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; const USER_ROLES = ['admin', 'editor', 'viewer'] as const; const ORDER_STATUSES = ['pending', 'processing', 'completed', 'cancelled'] as const; class User extends defineModel({ email: field.string().max(255), role: field.enum('user_role', USER_ROLES).default('viewer'), }) {} class Order extends defineModel({ orderNumber: field.string().max(50), status: field.enum('order_status', ORDER_STATUSES).default('pending'), }) {} // TypeScript enforces valid enum values const user = new User({ email: 'admin@example.com', role: 'admin', // Type-safe // role: 'superuser', // TypeScript error }); ``` Arrays field.array(elementType) PostgreSQL array field. Pass another field builder as the element type. TypeScript type is T[]. array-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Article extends defineModel({ title: field.string(), tags: field.array(field.string()), scores: field.array(field.int()).optional(), relatedIds: field.array(field.uuid()), }) {} const article = new Article({ title: 'Getting Started', tags: ['tutorial', 'beginner'], scores: [4, 5, 3], relatedIds: ['uuid-1', 'uuid-2'], }); ``` Objects field.object(shape) Nested object stored as JSONB. Define the shape using field builders. TypeScript infers the complete nested type. object-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Product extends defineModel({ name: field.string(), dimensions: field.object({ width: field.float(), height: field.float(), depth: field.float(), unit: field.string().max(10), }), shippingInfo: field.object({ weight: field.decimal(10, 2), carrier: field.string().optional(), }).optional(), }) {} const product = new Product({ name: 'Box', dimensions: { width: 10.5, height: 5.0, depth: 8.0, unit: 'cm', }, }); ``` References field.ref(Model) Reference to another model (many-to-one or one-to-one). Use a function for self-references to avoid hoisting issues. See TypeScript ```typescript Repositories > References ``` for full documentation. ref-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class User extends defineModel({ email: field.string(), name: field.string(), }) {} class Post extends defineModel({ title: field.string(), author: field.ref(User), // Reference parent: field.ref(() => Post), // Self-reference }) {} ``` field.refs(Model) Reference to multiple models (many-to-many). Creates a join table in the repository. refs-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Tag extends defineModel({ name: field.string().max(50).unique(), }) {} class Post extends defineModel({ title: field.string(), content: field.text(), tags: field.refs(Tag), // References - many-to-many }) {} ``` System Fields Special semantic fields for common lifecycle and versioning patterns: field.createdAt() Timestamp automatically set when the record is inserted. Maps to TIMESTAMP WITH TIME ZONE. field.updatedAt() Timestamp automatically updated whenever the record is saved. Maps to TIMESTAMP WITH TIME ZONE. field.deletedAt() Optional timestamp for soft deletes. When set, the record is considered deleted but remains in the database. Automatically marked as optional. field.version() Optimistic concurrency control version number. Automatically incremented on updates. Defaults to 1. system-fields-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Document extends defineModel({ title: field.string(), content: field.text(), createdAt: field.createdAt(), // Auto-set on insert updatedAt: field.updatedAt(), // Auto-updated on save deletedAt: field.deletedAt(), // Soft delete (optional) version: field.version(), // Optimistic locking (defaults to 1) }) {} ``` Field Modifiers All field builders support these common modifiers: .optional() Makes the field nullable. TypeScript type becomes T | undefined. optional-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class User extends defineModel({ email: field.string(), // Required bio: field.string().optional(), // Optional (can be undefined) age: field.int().optional(), // number | undefined }) {} const user = new User({ email: 'test@example.com' }); // bio and age are undefined ``` .default(value) Sets a default value when the field is not provided. Can be a static value or a function. default-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class User extends defineModel({ email: field.string(), isActive: field.boolean().default(true), balance: field.decimal(10, 2).default('0.00'), joinedAt: field.timestamp().default(() => new Date()), }) {} ``` .unique() Adds a unique constraint. PostgreSQL enforces uniqueness across all rows. unique-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class User extends defineModel({ email: field.string().max(255).unique(), username: field.string().max(50).unique(), }) {} ``` .index() Creates a database index for faster lookups. Use for frequently queried fields. index-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Post extends defineModel({ title: field.string(), slug: field.string().max(255).unique().index(), authorId: field.uuid().index(), // Foreign key index publishedAt: field.timestamp().index(), }) {} ``` .backfill(value) Provides a value for existing rows when adding a new non-nullable field via migrations. Required when adding non-nullable fields without defaults to tables with existing data. backfill-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; // Migration scenario: adding 'status' to existing User table class User extends defineModel({ email: field.string(), // Existing users will get 'active' status when column is added status: field.string().default('active').backfill('active'), }) {} ``` Chaining Modifiers Modifiers can be chained together fluently. Order doesn't matter: chaining-example.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Product extends defineModel({ // Chain multiple modifiers sku: field.string() .max(50) .unique() .index(), name: field.string() .max(200) .index(), price: field.decimal(10, 2) .default('0.00'), description: field.text() .optional(), }) {} ``` Next Steps Models Overview Repositories Overview References --- # Models URL: https://justscale.sh/docs/models/overview Models Define your domain models with type-safe field builders Models in JustScale define your domain entities using a declarative field builder API. They provide compile-time type safety, automatic TypeScript type inference, and separation between your domain schema and storage implementation. Creating a Model Use defineModel to create a model class. The model extends the returned class, and TypeScript automatically infers the types from your field definitions: user.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; // Define a User model with field builders class User extends defineModel({ email: field.string().max(255).unique(), name: field.string().max(100), balance: field.decimal(10, 2).default('0.00'), isActive: field.boolean().default(true), }) {} // TypeScript infers the type automatically: // { // email: string // name: string // balance: string // Decimals are strings for precision // isActive: boolean // } export { User }; ``` Creating Instances Models can be instantiated directly with new. The constructor accepts partial data for all fields. This creates a transient instance (not yet persisted): create-user.tsTypeScript ```typescript import { User } from './user.model'; // Create a new transient user (no id yet) const user = new User({ email: 'alice@example.com', name: 'Alice', // balance defaults to '0.00' // isActive defaults to true }); console.log(user.email); // 'alice@example.com' console.log(user.balance); // '0.00' ``` Field Builders Field builders provide a fluent API for defining field types and constraints. Each field type has specific modifiers and options: product.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Product extends defineModel({ // String fields sku: field.string().max(50).unique().index(), name: field.string().max(200), description: field.text(), // For long content // Number fields quantity: field.int().default(0), price: field.decimal(10, 2), // Precision for money weight: field.float().optional(), // Boolean inStock: field.boolean().default(true), // Date/Time manufacturedAt: field.timestamp().optional(), // JSON metadata: field.jsonb<{ tags: string[] }>().optional(), }) {} export { Product }; ``` See Field Builders Reference for a complete list of available field types and modifiers. Persistence and Type States When you persist a model via a repository, you get back a Persistent — a readonly version of your domain data. The adapter tracks identity, timestamps, and versioning internally via non-enumerable symbols. Your domain code never sees .id or system fields: user-service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import { ModelRepository, type Persistent } from '@justscale/core/models'; import { User } from './user.model'; export class UserService extends defineService({ inject: { users: ModelRepository.of(User) }, factory: ({ users }) => ({ create: async (email: string, name: string): Promise> => { const saved = await users.insert({ email, name }); saved.email; // string (readonly) saved.name; // string (readonly) // saved.id — does not exist! ID is an adapter concern return saved; }, findByEmail: async (email: string) => { return users.findOne(User.fields.email.eq(email)); }, }), }) {} ``` 💡Tip Persistent has the same fields as your model, but all readonly. To mutate a persistent entity, acquire a lock first — see Type States . If you need a raw identifier at a system boundary (URLs, external APIs), use the deliberately verbose escape hatch: TypeScript ```typescript const rawId = User.ref(savedUser).identifier; ``` For referencing entities in domain code, use typed references instead — see References . Permissions Models can declare who is allowed to do what directly in their definition. The permissions block receives the model's field expressions and returns a record of named checks built with permit(). Each check is stamped onto the model as Model.can.name and is also available as a route guard via the @justscale/permission middleware. ticket.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; import { permit, Everyone } from '@justscale/permission'; import { Customer } from './customer.model'; import { Agent } from './agent.model'; export class Ticket extends defineModel({ fields: { subject: field.string().max(255), body: field.text(), status: field.enum('TicketStatus', ['open', 'in_progress', 'resolved', 'closed']).default('open'), customer: field.ref(Customer), assignedAgent: field.ref(Agent).optional(), escalated: field.boolean().default(false), internalNotes: field.text().optional(), }, // 'customer' is the field expression — permit(Customer).when(customer) // means: the principal is a Customer whose ref equals this ticket.customer. permissions: ({ customer }) => ({ // Array = any of these passes view: [permit(Customer).when(customer), permit(Agent).always()], comment: [permit(Customer).when(customer), permit(Agent).always()], // Single rule assign: permit(Agent).always(), resolve: permit(Agent).always(), close: permit(Customer).when(customer), // Everyone is a principal that matches any caller — use for public rules viewPublicSummary: permit(Everyone).always(), }), }) {} ``` Using permission names The keys of the permissions record are stamped on Model.can: use-permissions.tsTypeScript ```typescript // As an HTTP route guard (@justscale/permission) Get('/tickets/:ticket') .types({ Ticket }) .guard(Ticket.can.view) .handle(({ params, res }) => { // Middleware proved the principal passes one of the 'view' rules. }); // Direct check with a resolved principal if (await Ticket.can.assign({ principal, subject: ticket })) { await ticketService.assign(ticket, agent); } ``` Queryable rules (ORM-style filters) Rules built with .when(field) are queryable: the same permission serves as both a row guard AND a WHERE-clause generator via .toCondition(principal). This is how you filter list endpoints without writing the predicate twice. queryable-permissions.tsTypeScript ```typescript // Declared once on the model: // close: permit(Customer).when(customer) // // At runtime, .toCondition(principal) evaluates to an EqCondition on the // same field — something the repository translates into SQL directly: const principal = await principals.current(); const condition = Ticket.can.view.toCondition(principal); // → EqCondition { field: 'customer', value: principal.ref } // → SQL: WHERE tickets.customer_id = :principal // Pass it straight to the repository — no manual predicate needed. const visible = await tickets.find({ where: condition }); // Combine with other filters via q.and / q.or: const openForMe = await tickets.find({ where: q.and(condition, Ticket.fields.status.eq('open')), }); ``` Traversing refs (JOIN-style) When the owning relation isn't direct, .when(field.has(Other.fields.X))walks the ref chain. It produces a nested HasCondition that the adapter compiles to a JOIN: traversal-permissions.tsTypeScript ```typescript // Attachment doesn't reference Customer directly — it references a Ticket, // which references a Customer. The traversal expresses the JOIN path. export class Attachment extends defineModel({ fields: { ticket: field.ref(Ticket), filename: field.string(), }, permissions: ({ ticket }) => ({ // permit(Customer).when(ticket.has(Ticket.fields.customer)) // guard: resolve attachment.ticket → check ticket.customer === principal // query: HasCondition { // field: 'ticket', // condition: EqCondition { field: 'customer', value: principal.ref } // } // SQL: JOIN tickets ON tickets.id = attachments.ticket_id // WHERE tickets.customer_id = :principal view: [permit(Customer).when(ticket.has(Ticket.fields.customer)), permit(Agent).always()], upload: [permit(Customer).when(ticket.has(Ticket.fields.customer)), permit(Agent).always()], }), }) {} ``` 💡Tip Prefer .when(field) whenever the rule can be expressed as a field comparison — you get ORM integration for free. Reach for .check(fn) only for logic that cannot be projected to a queryable condition (e.g. time-of-day checks, external policy lookups). Non-queryable rules work as guards but cannot filter list endpoints. Field-level access The optional access block attaches permissions to individual fields. Fields default to "visible and mutable by anyone allowed to see/edit the record," but access tightens specific fields to specific permissions: field-access.tsTypeScript ```typescript export class Ticket extends defineModel({ fields: { subject: field.string(), body: field.text(), internalNotes: field.text().optional(), assignedAgent: field.ref(Agent).optional(), escalated: field.boolean().default(false), }, permissions: ({ customer }) => ({ view: [permit(Customer).when(customer), permit(Agent).always()], assign: permit(Agent).always(), }), // access receives the permissions map — reuse named rules access: ({ assign }) => ({ // Only callers who pass 'assign' can see or set these internalNotes: assign, assignedAgent: assign, escalated: assign, }), }) {} // A Customer fetching a ticket gets back only subject/body — the // assign-gated fields are filtered out at serialization time. ``` Fields with no access entry follow the model-wide default (typically view); fields listed are scoped to the referenced permission. The same rule governs visibility (read) and mutability (write). 💡Tip permit(Everyone) is for rules that should pass for any caller (including anonymous). Declare Everyone-based rules last when you want owner-specific rules (e.g. viewAsOwner) to be checked first — earlier rules in an array win. Type Inference JustScale models use TypeScript's advanced type inference to extract types directly from your field definitions. You rarely need to write explicit types: type-inference.tsTypeScript ```typescript import { defineModel, field, type ModelData } from '@justscale/core/models'; class User extends defineModel({ email: field.string(), age: field.int().optional(), balance: field.decimal(10, 2), active: field.boolean().default(true), }) {} // Extract clean type reference type UserData = ModelData; // { // email: string // age: number | undefined // balance: string // active: boolean // } // TypeScript infers parameter types function greetUser(user: UserData) { console.log(`Hello, ${user.email}`); } const user = new User({ email: 'test@example.com', balance: '100.00' }); greetUser(user); // Type-safe! ``` Special Field Types Timestamps Use semantic timestamp fields for common patterns like created/updated tracking: post.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Post extends defineModel({ title: field.string(), content: field.text(), publishedAt: field.timestamp().optional(), createdAt: field.createdAt(), // Auto-set on insert updatedAt: field.updatedAt(), // Auto-updated on save deletedAt: field.deletedAt(), // Soft delete support (optional by default) }) {} export { Post }; ``` Enums Define PostgreSQL enums with type-safe values: order.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; const ORDER_STATUSES = ['pending', 'processing', 'completed', 'cancelled'] as const; class Order extends defineModel({ orderNumber: field.string().max(50).unique(), status: field.enum('order_status', ORDER_STATUSES).default('pending'), total: field.decimal(10, 2), }) {} // TypeScript knows status must be one of the enum values const order = new Order({ orderNumber: 'ORD-001', status: 'pending', // Type-safe // status: 'invalid', // TypeScript error total: '99.99', }); export { Order }; ``` Arrays Store arrays of primitive types using PostgreSQL array columns: tag.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; class Article extends defineModel({ title: field.string(), tags: field.array(field.string()), ratings: field.array(field.int()).optional(), }) {} const article = new Article({ title: 'TypeScript Tips', tags: ['typescript', 'programming', 'web'], ratings: [4, 5, 5, 3], }); export { Article }; ``` Best Practices Use semantic field types - Prefer field.createdAt() over field.timestamp() for timestamps Set appropriate constraints - Use .max(), .unique(), .index() to express domain rules Use decimal for money - Never use float or double for currency; use field.decimal(10, 2) Provide defaults - Set sensible defaults with .default() to reduce required constructor parameters Let TypeScript infer - Avoid manual type annotations; let the field builders do the work Export the class, not the instance - Export User class, not new User() Next Steps Field Builders Repositories Overview References --- # Installation URL: https://justscale.sh/docs/overview/installation Installation Get JustScale installed in your project Quick Setup The fastest way to start a new JustScale project is the installer. It detects your environment (package manager, IDE, CI provider) and scaffolds everything: Bash ```bash npx create-justscale ``` This creates a project with justscale.config.ts, two entry modes (HTTP and CLI), TypeScript configuration, and IDE/CI setup — ready to run with just dev. Manual Setup If you prefer to set things up yourself, or are adding JustScale to an existing project: Requirements Node.js 18 or later TypeScript 5.0 or later A package manager (pnpm, npm, yarn) Install Core Bash ```bash pnpm add @justscale/core ``` Install a Transport Choose how your application communicates with the outside world: Bash ```bash # HTTP (REST APIs) pnpm add @justscale/http # WebSocket (real-time) pnpm add @justscale/websocket # gRPC / RPC pnpm add @justscale/rpc ``` 💡Tip CLI commands are built into @justscale/core — no separate package needed. Use import { Cli } from '@justscale/core/cli'. Install the Compiler JustScale ships a custom TypeScript compiler that enables durable processes. Install it as a dev dependency: Bash ```bash pnpm add --save-dev @justscale/typescript ``` This provides ptsc (the compiler) and ptscserver(the language service). Your IDE picks it up automatically if you use the installer — otherwise point your IDE's TypeScript service to node_modules/@justscale/typescript. TypeScript Configuration JustScale requires strict TypeScript settings for full type safety: JSON ```json { "compilerOptions": { "strict": true, "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext" } } ``` Optional Packages Install additional packages based on your needs: Bash ```bash # PostgreSQL adapter pnpm add @justscale/postgres # Testing utilities pnpm add --save-dev @justscale/testing # Authentication feature pnpm add @justscale/auth # OpenTelemetry observability pnpm add @justscale/feature-otel ``` Next Steps Quick Start Services --- # Introduction URL: https://justscale.sh/docs/overview/introduction Introduction A TypeScript framework where domain code describes what happens, not how. The Problem Modern backend development has an infrastructure obsession. We spend more time wiring databases, managing connections, handling transactions, configuring authentication, and dealing with deployment than writing actual business logic. Look at any backend codebase. The core logic — what the application actually does — is a small fraction of the code. The rest is infrastructure: how data gets stored, how services communicate, how errors propagate, how state survives restarts. We've accepted this as normal. It isn't. The T-Shirt Test There's a running joke about programmer t-shirts with silly pseudocode: python ```python while alive: if not coffee: get_coffee() drink_coffee() work() ``` We laugh because "real code doesn't work like this." But why doesn't it? The logic is clear. The intent is obvious. We understand it instantly. Now consider a poker game. The rules are simple: deal cards, bet in rounds, reveal the winner. Here's what that looks like in JustScale: TypeScript ```typescript // Pre-flop await bettingRound('preflop'); const afterPreflop = lastStanding(); if (afterPreflop) return afterPreflop; // Flop deck.pop(); // burn exports.communityCards.push(deck.pop()!, deck.pop()!, deck.pop()!); await bettingRound('flop'); const afterFlop = lastStanding(); if (afterFlop) return afterFlop; // Turn deck.pop(); // burn exports.communityCards.push(deck.pop()!); await bettingRound('turn'); const afterTurn = lastStanding(); if (afterTurn) return afterTurn; // River deck.pop(); // burn exports.communityCards.push(deck.pop()!); await bettingRound('river'); // Showdown const winner = determineWinner(communityCards, remaining); ``` 💡Tip This isn't pseudocode. This is from a real process that survives server restarts, works across multiple instances, and scales to thousands of concurrent games. Each bettingRound suspends the process while waiting for player actions — for minutes, hours, or until a timeout. The compiler transforms it into a resumable state machine. JustScale's goal: make the t-shirt code real. Your domain logic should read like a description of what happens, not a manual for how infrastructure makes it happen. What Makes JustScale Different Durable Processes as Plain Code Long-running workflows — subscriptions, order fulfillment, game sessions — are written as plain TypeScript. The compiler transforms them into state machines that persist to storage and resume after restarts. TypeScript ```typescript const r = race(); switch (true) { case signal(r, signals.paymentConfirmed): return { status: 'paid', txId: r.txId }; case delay.days(r, 3): return { status: 'timeout' }; } ``` ID-Free Domain An ID is an infrastructure detail — how your storage tracks entities. In JustScale, domain code never sees string IDs. Persistent entities are references themselves. TypeScript ```typescript // Pass entities directly — no .id needed await transfer(fromAccount, toAccount, amount); // At boundaries, convert strings to typed refs const user = User.ref`${userId}`; ``` Type States as Contracts The shape of your data tells you what you can do with it. A method that needs a locked entity says so in its signature — and the compiler enforces it. TypeScript ```typescript async markPaid(this: Lock>) { this.status = 'paid'; // Lock removes readonly — safe to mutate } async loadItems(this: Persistent) { return this.payments.getItemsFor(this); // Read-only access } ``` Transport Agnostic Controllers are entry points, not HTTP handlers. The same business logic works with HTTP, WebSocket, CLI, gRPC, or events — same DI, same middleware, same guards. poker.controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Ws } from '@justscale/websocket/builder'; import { PokerService } from './poker.service.js'; export const PokerController = createController('/poker', { inject: { poker: PokerService }, routes: ({ poker }) => ({ table: Ws('/:tableId') .message(Command) .handle(async ({ messages, send, params }) => { await poker.openTable(params.tableId); for await (const msg of messages) { switch (msg.type) { case 'sit': await poker.sitDown(params.tableId, msg.playerId, msg.seatNumber, msg.buyIn); break; case 'action': await poker.playerAction(msg.gameId, msg.playerId, msg.action, msg.amount); break; } } }), }), }); ``` How It All Fits Together JustScale is a full-stack backend framework with compile-time type safety. No decorators, no runtime reflection — plain TypeScript functions and objects. What you write is what runs. Dependency Injection — type-safe, compile-time validated. Missing dependencies are caught before your code runs. Models — domain entities with type-safe field builders, references, and methods that declare their data requirements. Repositories — abstract storage. Swap PostgreSQL for in-memory without touching domain code. Processes — durable workflows that survive restarts, with signals, race conditions, and timeouts. Features — composable modules bundling services, controllers, and configuration. Next Steps Installation Quick Start Philosophy --- # Philosophy URL: https://justscale.sh/docs/overview/philosophy Philosophy The nine principles behind JustScale's design JustScale is built on a simple observation: most backend code is infrastructure, not business logic. These nine principles guide every design decision in the framework. Domain Purity 1. Domain code describes WHAT, never HOW Your code should read like a description of what happens, not how it happens. A subscription that charges monthly, cancels on request, and times out after inactivity should look exactly like that: Files srcdomain.tsservices.tssubscription.process.ts srcdomain.tsservices.tssubscription.process.ts src/subscription.process.tsTypeScript ```typescript import { createProcess, signal, race, delay } from '@justscale/core/process'; import { User } from './domain'; import { BillingService, BillingSignals, NotificationService } from './services'; // A subscription that charges monthly and cancels on request. // Runs for months. Survives server restarts. Works across multiple instances. // But reads like a plain loop with a timer. export const subscription = createProcess({ path: '/subscription/:user', types: { User }, // :user is Reference in the handler inject: { billing: BillingService, notifications: NotificationService, signals: BillingSignals, }, async handler({ billing, notifications, signals }, { user }) { while (true) { const r = race(); switch (true) { case delay.days(r, 30): await billing.charge(user); await notifications.send(user, 'Payment processed'); continue; case signal(r, signals.cancellation): await notifications.send(user, 'Subscription cancelled'); return { status: 'cancelled' as const }; } } }, }); ``` This process runs for months. It survives server restarts. It works across multiple instances. But the code reads like a simple loop with a monthly timer. 2. IDs do not exist in domain code An ID is an infrastructure detail — it's how your storage layer tracks entities. It's not a domain concept. Raw strings enter the app at the boundary (a route param, an event, a signal payload) and are immediately branded into typed references. From there, domain code just awaits them. Files srcdomain.tsroute.ts srcdomain.tsroute.ts src/route.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { User, Order } from './domain'; // The boundary — and even here no raw string ID appears. `.types({ User })` // matches the `:user` path param against the model key and brands it into a // `Reference` before the handler runs. Awaiting a Reference hydrates // it to `Persistent`. export const getMyOrder = Get('/users/:user/order') .types({ User, Order }) .handle(async ({ params, res }) => { const user = await params.user; // Reference → Persistent | null if (!user) { res.status(404).end(); return; } // user.currentOrder is already a typed ref (field.ref(Order)). Just await. // No lookup call, no .orderId leaking through, no cast. const order = user.currentOrder && await user.currentOrder; res.json({ order }); }); ``` Persistentmeans "this entity is stored." That's it. No .id, no .createdAt, no system fields. Those are adapter concerns, stored internally via non-enumerable symbols. 3. Adapters own their concerns The domain defines models. Adapters implement storage. The adapter decides what IDs look like, how timestamps work, and how queries are optimized. Files srcdomain.tsmemory.tspg.ts srcdomain.tsmemory.tspg.ts src/domain.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; // Pure domain — no IDs, no timestamps, no storage hints. Just fields. export class User extends defineModel({ fields: { email: field.string().max(255).unique(), name: field.string().max(100), }, }) {} ``` Domain code never changes when you swap adapters. Type System as Contract 4. You tell us what you need, we give you data in the right form Methods declare what form of data they need via this parameter types. The type signature IS the contract — if it compiles, the caller provided the right data in the right state. Files srcorder.tspayments.ts srcorder.tspayments.ts src/order.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; import type { Lock, Persistent } from '@justscale/core/models'; import { PaymentService } from './payments'; export class Order extends defineModel({ fields: { amount: field.int(), status: field.enum('OrderStatus', ['pending', 'paid', 'shipped']).default('pending'), }, inject: { payments: PaymentService }, }) { // I need a locked persistent order — safe to mutate, nobody else can write concurrently. async markPaid(this: Lock>) { this.status = 'paid'; } // I need a stored order — read-only access, no lock needed. async loadItems(this: Persistent) { return this.payments.getItemsFor(this); } // Works on anything — doesn't touch fields, so no `this` type needed. validate() { return this.amount > 0; } } ``` 💡Tip Type states: Transient is unsaved and writable. Persistent is stored and readonly. Lock> is stored, locked, and writable. The compiler enforces these contracts. 5. Models are services A model instance is not just data. Its prototype is a resolved service — a singleton with injected dependencies that every instance shares. text ```text instance (own props: field data) → modelService (injected deps, non-enumerable) → ModelClass.prototype (methods from class body) → BaseModel.prototype ``` When you call this.payments on a model instance, it walks the prototype chain to the resolved service. All instances share the same service. Fields are own properties. Methods come from the class. Dependencies come from the prototype. 6. If it compiles, it works The type system is not documentation — it's a contract enforcement mechanism. Every relationship between components is verified at compile time: Missing dependencies → type error Wrong data form (need locked, got readonly) → type error Wrong reference type → type error Impossible cross-adapter query → detected at boot The goal: zero runtime surprises that could have been caught statically. Invisible Infrastructure 7. References replace relationships You don't store foreign keys. You store references — type-safe, memoized, and scoped to the current framework instance. Files srcmodels.tsroute.ts srcmodels.tsroute.ts src/route.tsTypeScript ```typescript import { Get } from '@justscale/http'; import { Campaign } from './models'; // Path param becomes Reference via .types(). The handler never sees // a string ID. Related entities are typed refs — awaiting them hydrates. export const getCampaign = Get('/campaigns/:campaign') .types({ Campaign }) .handle(async ({ params, res }) => { const campaign = await params.campaign; if (!campaign) { res.status(404).end(); return; } // `field.ref(Creator)` is Reference — PromiseLike, just await. const creator = await campaign.creator; // `field.refs(Tag)` is References — also PromiseLike; resolves all at // once (batched — Postgres uses a dataloader, in-memory resolves inline). const tags = await campaign.tags; res.json({ title: campaign.title, by: creator?.name, tags: tags.map((t) => t.label), }); }); ``` Ref unifies all ways to point at an entity: Reference | Persistent | Lock>. Services accept Ref — pass a persistent entity directly, or convert a string at the boundary with User.ref`${userId}`. 8. Async context is the framework JustScale uses AsyncLocalStorageto track which instance you're in. This means: No context objects to pass around — Model.ref(entity) works anywhere Multiple framework instances in one process are isolated (multi-tenancy) Works in callbacks, setTimeout, external library code — the async context is always there 9. Durable processes as plain code Long-running workflows are written as plain TypeScript that the compiler transforms into resumable state machines with opcode-based persistence. campaign-lifecycle.process.tsTypeScript ```typescript export const campaignLifecycle = createProcess({ path: '/campaign/:campaign/lifecycle', types: { Campaign }, // :campaign → Ref in the handler inject: { campaigns: CampaignService, pledges: PledgeService, payments: PaymentService }, async handler({ campaigns, pledges, payments }, { campaign }) { const found = await campaign; // resolve the Ref to a Persistent if (!found) return { status: 'failed', reason: 'not-found' }; const r = race(); switch (true) { case signal(r, pledges.fullyFunded): { await campaigns.setStatus(campaign, 'settling'); for (const pledgeId of await pledges.findIds(campaign)) { await payments.charge(Pledge.ref(pledgeId)); } return { status: 'completed', campaign: found }; } case delay.days(r, found.durationDays): { await campaigns.setStatus(campaign, 'failed'); return { status: 'failed', campaign: found }; } } }, }); ``` This compiles to an opcode-based state machine. It survives restarts, works across multiple instances, and scales to millions of concurrent processes. Write for One, Scale to Many The best code doesn't know it's distributed. When you write a goroutine in Go, you write straightforward, synchronous-looking code. You don't think about thread pools or CPU scheduling. The Go runtime handles those complexities invisibly. JustScale brings this same philosophy to distributed systems. You write code as if your application runs on a single instance — acquire a lock, update an entity, store some state — and the framework transparently handles clustering, distributed coordination, and horizontal scaling. Need to ensure only one instance processes a task? Just use using lock = await lockService.acquire(entity). The framework decides whether that's a local mutex or a distributed lock based on your deployment. Your code doesn't change. It just scales. This philosophy appears throughout great programming tools: Go's goroutines — synchronous-looking code, the runtime makes it concurrent React's declarative UI — describe what the UI should look like, React figures out the mutations SQL — express what data you want, the engine chooses execution plans JustScale follows this tradition: you write simple, instance-local code, and the framework handles distributed concerns automatically. What enforces this The analogy would be empty if the framework merely hopedyour code behaved distributed-safely. It doesn't. Four mechanical rules, all type-checked, close the loop: Every mutating repository method requires Locked. save, update, and delete refuse a bare Persistent at compile time. The only way to obtain a Locked is repo.lock(ref). repo.lock() is atomic with the read. On Postgres it is a single SELECT ... FOR UPDATE; the entity contents come from a row read under the lock, not from whatever the caller passed in. Stale-write bugs are structurally impossible. Locked cannot cross process boundaries. The serializer throws if a Locked reaches a signal payload or any other wire format. Lock guarantees are a local async-context fact; sending one across the network would be a lie, so the framework refuses to. Signals carry routable identity, not free-form payloads. defineSignals forces every path parameter through .types({Model}); the path is the topic on the pg NOTIFY bus, and the typed params are the routing key. A signal that cannot be routed cannot be defined. The result: the same domain code that runs on your laptop against an in-memory lock provider runs correctly on a 20-node deployment against Postgres advisory locks, without a single line of change. Not because the framework is clever, but because the type system already refused everything that would have been wrong. For the full mechanical walkthrough — including the multi-process end-to-end test that proves this — see Why It Scales . Common Questions Why no decorators? Two reasons. One is mechanical — decorators lean on runtime reflection (reflect-metadata), emit hidden side effects, and TypeScript can't infer much through them. Plain functions are simpler: what you see is what runs. The bigger reason is that decorators are isolated. Each decorator runs in its own context and can't see what another decorator did. A chained builder does the opposite — each step returns a new builder whose type remembers what the previous step added, so later steps are type-safe in terms of earlier ones: TypeScript ```typescript Get('/users/:user/tickets') .types({ User }) // params.user: string → Reference .use(auth) // adds { user: Persistent } to ctx .use(loadTickets) // sees ctx.user + params.user — both typed .guard(canListTickets) // sees everything the chain has accumulated .returns(200, TicketListSchema) .handle(({ params, user, tickets, res }) => { // params.user: Reference — from .types() // user: Persistent — from .use(auth) // tickets: Persistent[] — from .use(loadTickets) res.json(tickets) }) ``` With decorators, @LoadTickets has no idea whether @Auth ran, what it added, or what shape the route params have. You reach for runtime any and hope. With a chain, if you put .use(loadTickets) before .use(auth), TypeScript catches it — the required userin its input isn't in scope yet. There's a third, subtler problem. Decorators fuse the type graph with the module graph. @Inject() private repo: UserRepository only works because TypeScript emits the value of UserRepository alongside the decorator and reflect-metadatareads it at class-creation time. A property's type annotation is now a live runtime import. That coupling breaks in three ways: Circular imports move from compile-time to runtime. A cycle at the type level is fine; a cycle where each decorator fires and needs the sibling class as a value produces a silent undefined on whichever side initialises second. The bug depends on import order. Type-only imports stop being an escape hatch. import type erases at compile time — useless if the decorator needs the class as a value. Same file, different identity. Monorepos resolve the same module through path aliases, workspace protocols, symlinks, nested node_modules. With decorators, two canonical paths become two tokens in the DI container — silently splitting a singleton in half. JustScale passes the token as a value: inject: { repo: UserRepository }. That's a plain JavaScript object — no reflection, no metadata, no implicit link between a type annotation and the module graph. Node's module cache deduplicates imports for us, so every path that resolves to the same file yields the same class reference and the same token. Rename a path alias, split a file, move to project references — nothing reaches across and breaks. Why abstract classes for repositories? Abstract classes (vs interfaces) provide runtime tokens for dependency injection and support instanceofchecks. They're the best balance of type safety and runtime utility in TypeScript. Next Steps Services Models Overview Type States --- # Quick Start URL: https://justscale.sh/docs/overview/quick-start Quick Start Build your first JustScale application in 5 minutes Create a New Project Bash ```bash npx create-justscale ``` The installer detects your package manager, IDE, and CI provider, then scaffolds a ready-to-run project. When it's done: Bash ```bash just dev ``` 💡Tip just is JustScale's CLI — it's installed with @justscale/core and handles dev server, builds, tests, and plugin management. Create Your First Service Services contain your business logic. Create src/services/greeting.ts: Files srccontrollersgreeting.ts servicesgreeting.ts index.ts srccontrollersgreeting.ts servicesgreeting.ts index.ts src/services/greeting.tsTypeScript ```typescript import { defineService } from '@justscale/core' export class GreetingService extends defineService({ inject: {}, factory: () => ({ greet: (name: string) => `Hello, ${name}!`, farewell: (name: string) => `Goodbye, ${name}!`, }), }) {} ``` Create a Controller Controllers group related routes. Create src/controllers/greeting.ts: Files srccontrollersgreeting.ts servicesgreeting.ts index.ts srccontrollersgreeting.ts servicesgreeting.ts index.ts src/controllers/greeting.tsTypeScript ```typescript import { createController } from '@justscale/core' import { Get } from '@justscale/http' import { z } from 'zod' import { GreetingService } from '../services/greeting' // Response schema for type safety & OpenAPI generation const MessageResponse = z.object({ message: z.string(), }) export const GreetingController = createController('/greet', { inject: { greeting: GreetingService }, routes: (services) => ({ hello: Get('/:name') .returns(200, MessageResponse) .handle(({ params, res }) => { const message = services.greeting.greet(params.name) res.json({ message }) }), goodbye: Get('/bye/:name') .returns(200, MessageResponse) .handle(({ params, res }) => { const message = services.greeting.farewell(params.name) res.json({ message }) }), }), }) ``` Create the Application Wire everything together in src/index.ts: Files srccontrollersgreeting.ts servicesgreeting.ts index.ts srccontrollersgreeting.ts servicesgreeting.ts index.ts src/index.tsTypeScript ```typescript import JustScale from '@justscale/core' import { defaultHttpConfig } from '@justscale/http/testing' import { GreetingController } from './controllers/greeting' import { GreetingService } from './services/greeting' const app = JustScale() .add(defaultHttpConfig) .add(GreetingService) .add(GreetingController) .build() await app.serve() console.log('Server running on http://localhost:3000') ``` Run It Bash ```bash just dev ``` Test your API: Bash ```bash curl http://localhost:6142/greet/World # {"message":"Hello, World!"} curl http://localhost:6142/greet/bye/World # {"message":"Goodbye, World!"} ``` What's Next? Add Validation Use Zod schemas to validate request body and declare response types: users-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post } from '@justscale/http'; import { body } from '@justscale/http/builder'; import { z } from 'zod'; import { UserService } from './user-service'; const CreateUserBody = z.object({ email: z.string().email(), name: z.string().min(2), }); const UserResponse = z.object({ user: z.object({ email: z.string(), name: z.string(), }), }); const UsersController = createController('/users', { inject: { users: UserService }, routes: (services) => ({ create: Post('/') .body(CreateUserBody) .returns(201, UserResponse) .handle(async ({ body, res }) => { // body is typed as { email: string; name: string } const user = await services.users.create(body); res.status(201).json({ user }); }), }), }); ``` Add Models & Persistence Define domain models with type-safe field builders and persist them with Repositories . In JustScale, models are pure domain objects — no IDs or system fields in your code. Add Features Use Features for production-ready functionality like pub/sub channels, interactive shell, and more. Next Steps Services Controllers Models Overview --- # Migrations URL: https://justscale.sh/docs/postgres/migrations Migrations Schema migrations for PostgreSQL JustScale ships a migration system for PostgreSQL: defineMigration files on disk, a registry populated as a side effect of importing them, and the just migrate CLI that runs and tracks them. Migrations are generated artifacts — author your domain models, then run just migrate make to scaffold a migration that diffs your models against the current database. Migration Files A migration is a defineMigration file with up and down handlers that take a typed Database API. You can write them by hand, but the usual flow is to let just migrate make generate one and only edit when the generator can't express what you need. migrations/20240102_add_posts.tsTypeScript ```typescript import { defineMigration } from '@justscale/postgres'; import { field } from '@justscale/core/models'; export default defineMigration({ async up({ db }) { await db.createTable('posts', { id: field.uuid().primaryKey(), title: field.string().max(200), content: field.text().optional(), authorId: field.uuid(), publishedAt: field.timestamp().optional(), createdAt: field.createdAt(), }); await db.addForeignKey('posts', 'authorId', { table: 'users', column: 'id' }, { onDelete: 'CASCADE' } ); await db.createIndex('posts', ['authorId']); }, async down({ db }) { await db.dropTable('posts'); }, }); ``` Wiring the Migration Feature You don't instantiate the runner directly. Add PostgresMigrationFeature to your app and it contributes the just migrate CLI commands plus the runtime that tracks applied batches in a _migrations table. app.tsTypeScript ```typescript import JustScale from '@justscale/core'; import { PostgresClient, PostgresMigrationFeature } from '@justscale/postgres'; const app = JustScale() .add(PostgresClient) .add(PostgresMigrationFeature) .build(); await app.serve({ http: 3000 }); ``` For dev-only commands (just migrate make, fresh, verify), additionally add PostgresMigrationDevFeature from @justscale/postgres/dev. Database API The Database API provides a clean interface for schema operations in migrations: Table Operations table-operations.tsTypeScript ```typescript // Create table await db.createTable('products', { id: field.uuid().primaryKey(), name: field.string().max(200), price: field.decimal(10, 2), }); // Alter table await db.alterTable('products', (table) => { table.addColumn('sku', field.string().max(50).unique()); table.dropColumn('legacy_field'); table.renameColumn('old_name', 'new_name'); table.setNotNull('required_field'); table.setNullable('optional_field'); table.setDefault('status', "'active'"); table.dropDefault('status'); }); // Drop table await db.dropTable('products'); // Rename table await db.renameTable('old_name', 'new_name'); // Check if table exists if (await db.hasTable('products')) { // ... } ``` Index Operations index-operations.tsTypeScript ```typescript // Simple index await db.createIndex('users', 'email'); // Composite index await db.createIndex('orders', ['customer_id', 'created_at']); // Unique index await db.createIndex('users', 'username', { unique: true }); // Partial index with condition await db.createIndex('users', 'email', { where: "status = 'active'", }); // GIN index for JSONB await db.createIndex('documents', 'metadata', { using: 'gin' }); // Drop index await db.dropIndex('idx_users_email'); ``` Foreign Keys foreign-keys.tsTypeScript ```typescript // Add foreign key await db.addForeignKey('posts', 'author_id', { table: 'users', column: 'id' }, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' } ); // Drop foreign key await db.dropForeignKey('posts', 'fk_posts_author_id'); // Available referential actions: // - CASCADE: Delete/update related rows // - SET NULL: Set FK to NULL // - SET DEFAULT: Set FK to default value // - RESTRICT: Prevent operation // - NO ACTION: Similar to RESTRICT (default) ``` Enum Types enum-operations.tsTypeScript ```typescript // Create enum await db.createType('UserStatus', ['active', 'inactive', 'suspended']); // Add value to existing enum await db.addTypeValue('UserStatus', 'banned', { after: 'suspended' }); // Or add at the beginning await db.addTypeValue('UserStatus', 'pending', { before: 'active' }); // Drop enum (no columns can reference it) await db.dropType('UserStatus'); // Drop if exists await db.dropTypeIfExists('UserStatus'); ``` Constraints constraints.tsTypeScript ```typescript // Add unique constraint await db.addUnique('users', 'email'); await db.addUnique('products', ['vendor_id', 'sku'], 'unique_vendor_sku'); // Drop unique constraint await db.dropUnique('users', 'users_email_key'); // Add check constraint await db.addCheck('products', 'price_positive', 'price > 0'); // Drop check constraint await db.dropCheck('products', 'price_positive'); ``` CLI Commands With PostgresMigrationFeature wired (and PostgresMigrationDevFeature for the dev-only commands), the following commands are available via the cluster socket: Bash ```bash # Run pending migrations just migrate # Show migration status just migrate status # Rollback last batch just migrate rollback # Show pending migrations just migrate pending # Dev-only: generate a migration by diffing models against the DB just migrate make add_avatar_to_users # Dev-only: reset and re-run all migrations (destructive) just migrate fresh ``` Schema Tracking The migration runner uses a tracking table (default: _migrations) to record which migrations have been applied: SQL ```sql CREATE TABLE _migrations ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, batch INTEGER NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` Each migration is recorded with its name and batch number. When you roll back, the entire batch is reverted in reverse order. Data Operations For seeder migrations, the Database API includes data manipulation methods: migrations/20240103_seed_admin.tsTypeScript ```typescript import { defineMigration } from '@justscale/postgres'; export default defineMigration({ async up({ db }) { // Check if already seeded (idempotent) const exists = await db.exists('users', { email: 'admin@example.com' }); if (exists) { return; } // Insert admin user const admin = await db.insert('users', { email: 'admin@example.com', name: 'Admin User', status: 'active', }); // Insert multiple records await db.insertMany('products', [ { name: 'Product A', price: '19.99' }, { name: 'Product B', price: '29.99' }, ]); }, async down({ db }) { await db.delete('users', { email: 'admin@example.com' }); }, }); ``` Available data methods: insert, insertMany,update, delete, exists, find, and findOne. Best Practices Treat migrations as generated artifacts. Author your domain models, run just migrate make, and commit the result. If the generated SQL is wrong, fix the model or the generator and regenerate — don't hand-edit the migration file. Reversible by default. The generator emits both up and down; keep both in sync if you do edit by hand. Test against a real database. Run just migrate against a fresh local Postgres before shipping. pglite is fine for unit tests and CLI tooling, but local just dev uses real Postgres. Watch DDL transactionality. Migrations run in a transaction, but some PostgreSQL DDL (e.g. CREATE INDEX CONCURRENTLY) cannot participate. Split those into their own migration. Commit the migration files.They're part of the source — every collaborator and every environment derives the same schema by importing them. Raw SQL For complex operations, you can execute raw SQL in migrations: raw-sql-migration.tsTypeScript ```typescript import { defineMigration } from '@justscale/postgres'; export default defineMigration({ async up({ db }) { // Execute raw SQL await db.raw(` CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; `); // Query and get results const results = await db.query('SELECT * FROM users WHERE status = $1'); }, async down({ db }) { await db.raw('DROP FUNCTION IF EXISTS update_updated_at()'); }, }); ``` Next Steps PostgreSQL Overview PostgreSQL Repositories Models Overview --- # PostgreSQL Overview URL: https://justscale.sh/docs/postgres/overview PostgreSQL Overview PostgreSQL adapter for persistent storage in JustScale The PostgreSQL adapter provides a production-ready storage backend for JustScale applications. It wraps domain models with PostgreSQL-specific configuration, separating domain concerns from storage implementation details. Installation Install the PostgreSQL adapter package: Bash ```bash pnpm add @justscale/postgres ``` Core Concepts Domain Models vs Storage Models JustScale separates domain models (pure business logic) from storage models (database-specific configuration): Domain Model - Defined with defineModel, contains field types and validation, no storage details Storage Model - Created with createPgModel, wraps the domain model with table name, indexes, column overrides Repository - Created with createPgRepository, provides query methods and integrates with DI Quick Start Here is a complete example showing all three layers: user-model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; import { createPgModel, createPgRepository } from '@justscale/postgres'; // 1. Define domain model (pure, no storage details) class User extends defineModel({ email: field.string().max(255).unique(), name: field.string().max(100), status: field.enum('UserStatus', ['active', 'inactive', 'banned'] as const).default('active'), balance: field.decimal(10, 2).default('0.00'), }) {} // 2. Create storage model with PostgreSQL config const PgUser = createPgModel(User, { table: 'users', // Optional: defaults to snake_case + 's' indexes: [ { fields: ['email'], unique: true }, { fields: ['status'] }, ], }); // 3. Create repository service const UserRepository = createPgRepository(PgUser); export { User, UserRepository }; ``` Storage Modes The PostgreSQL adapter supports two storage modes: Columnar Mode (Default) Each field maps to a database column. Provides best query performance and full SQL capabilities. columnar-mode.tsTypeScript ```typescript import { createPgModel } from '@justscale/postgres'; import { User } from './user-model'; const PgUser = createPgModel(User, { table: 'users', storageMode: 'columnar', // Default, can be omitted }); // Creates table: users (id, email, name, status, balance, created_at, updated_at, version) ``` JSONB Mode Fields are stored in a JSONB column. Useful for schema flexibility and rapid iteration. System fields (id, createdAt, updatedAt, version) remain as columns. jsonb-mode.tsTypeScript ```typescript import { createPgModel } from '@justscale/postgres'; import { Product } from './product-model'; const PgProduct = createPgModel(Product, { table: 'products', storageMode: 'jsonb', dataColumn: 'data', // Optional: defaults to 'data' }); // Creates table: products (id, data, created_at, updated_at, version) // All domain fields stored in the 'data' JSONB column ``` Table Naming Conventions By default, table names are automatically generated from model names using snake_case and pluralization: table-naming.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; import { createPgModel } from '@justscale/postgres'; class User extends defineModel({ name: field.string() }) {} const PgUser = createPgModel(User); // Table: 'users' class BlogPost extends defineModel({ title: field.string() }) {} const PgBlogPost = createPgModel(BlogPost); // Table: 'blog_posts' class Category extends defineModel({ name: field.string() }) {} const PgCategory = createPgModel(Category, { table: 'categories' }); // Table: 'categories' (explicit override) ``` Column Overrides Override inferred PostgreSQL types or add constraints for specific fields: column-overrides.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; import { createPgModel } from '@justscale/postgres'; class User extends defineModel({ email: field.string().max(255), tags: field.array(field.string()), metadata: field.jsonb(), }) {} const PgUser = createPgModel(User, { table: 'users', overrides: { email: { type: 'CITEXT', unique: true }, // Case-insensitive text tags: { type: 'TEXT[]' }, // Native PostgreSQL array }, }); ``` Connecting to PostgreSQL Supply the connection string through a secret provider and add PostgresFeature — it provides AbstractPostgresClient for DI. The channel and lock features layer on LISTEN/NOTIFY pub/sub and distributed advisory locks over the same connection: database-setup.tsTypeScript ```typescript import JustScale, { createSecretProvider } from '@justscale/core'; import { PostgresFeature, PostgresChannelFeature, PostgresLockFeature, PostgresSecrets, } from '@justscale/postgres'; const Secrets = createSecretProvider({ provides: [PostgresSecrets], factory: () => ({ [PostgresSecrets.key]: { connectionString: process.env.DATABASE_URL! }, }), }); const app = JustScale() .add(Secrets) .add(PostgresFeature) // provides AbstractPostgresClient .add(PostgresChannelFeature) // provides AbstractChannelBackend (LISTEN/NOTIFY) .add(PostgresLockFeature) // distributed advisory locks .build(); ``` Tune the connection pool with a PostgresClientConfig partial (max, idleTimeout, connectTimeout) — or adjust it at runtime with the config CLI: pool-config.tsTypeScript ```typescript import { createConfig } from '@justscale/core'; import { PostgresClientConfig } from '@justscale/postgres'; const PoolConfig = createConfig({ provides: [PostgresClientConfig], factory: () => ({ [PostgresClientConfig.key]: { max: 25, // Connection pool size idleTimeout: 20, // Seconds connectTimeout: 10, // Seconds }, }), }); // Or at runtime: just config set postgres:client max 25 ``` Need a custom secret shape, multiple databases, or hand-built wiring? The low-level createPostgresClient / createPostgresChannelBackend factories live in @justscale/postgres/advanced. Type Safety Repositories are fully typed based on your domain models: type-safety.tsTypeScript ```typescript import { User, UserRepository } from './user-model'; class UserService extends defineService({ inject: { users: UserRepository }, factory: ({ users }) => ({ async findActive() { // Type-safe field references return users.find({ where: User.fields.status.eq('active'), orderBy: { createdAt: 'desc' }, }); // Returns: Persistent[] // Each entity has: id, email, name, status, balance, createdAt, updatedAt, version }, }), }) {} ``` Best Practices Separate domain from storage - Define models with defineModel, then wrap with createPgModel Use columnar mode for production - Better performance for queries, indexes, and constraints Use JSONB mode for rapid iteration - Schema changes without migrations during development Leverage type safety - Use field expressions like User.fields.email.eq('...') for compile-time validation Configure indexes - Add indexes for frequently queried fields to improve performance What's Next Now that you understand the basics, learn about repository methods, transactions, and advanced features: Next Steps PostgreSQL Repositories Queries Overview Models Overview --- # PostgreSQL Repositories URL: https://justscale.sh/docs/postgres/repositories PostgreSQL Repositories Query methods, transactions, and advanced features PostgreSQL repositories provide a complete set of query methods for working with your data. All methods are type-safe and integrate seamlessly with JustScale's dependency injection system. Repository Methods The PgRepository class provides these core methods: Finding Entities finding-entities.tsTypeScript ```typescript import { User, UserRepository } from './user-model'; class UserService extends defineService({ inject: { users: UserRepository }, factory: ({ users }) => ({ // Find all matching a condition async findActive() { return users.find({ where: User.fields.status.eq('active'), orderBy: { createdAt: 'desc' }, limit: 10, offset: 0, }); }, // Get by typed reference async getUser(user: Ref) { return users.get(user); // Returns: Persistent | undefined }, // Find first match async findByEmail(email: string) { return users.findOne(User.fields.email.eq(email)); // Returns: Persistent | undefined }, }), }) {} ``` Counting and Existence counting.tsTypeScript ```typescript import { User, UserRepository } from './user-model'; class UserService extends defineService({ inject: { users: UserRepository }, factory: ({ users }) => ({ // Count matching rows async countActive() { return users.count(User.fields.status.eq('active')); // Returns: number }, // Check existence async emailExists(email: string) { return users.exists(User.fields.email.eq(email)); // Returns: boolean }, }), }) {} ``` Inserting Entities inserting.tsTypeScript ```typescript import { User, UserRepository } from './user-model'; class UserService extends defineService({ inject: { users: UserRepository }, factory: ({ users }) => ({ // Insert single entity async createUser(data: { email: string; name: string }) { return users.insert(data); // Returns: Persistent (with id, createdAt, updatedAt, version) }, // Insert many (bulk insert) async createMany(data: Array<{ email: string; name: string }>) { return users.insertMany(data); // Returns: Persistent[] }, }), }) {} ``` Updating Entities updating.tsTypeScript ```typescript import { User, UserRepository } from './user-model'; class UserService extends defineService({ inject: { users: UserRepository }, factory: ({ users }) => ({ // Update by ID async updateName(id: string, name: string) { return users.update(id, { name }); // Returns: Persistent }, // Update with optimistic locking async updateWithVersion(id: string, name: string, expectedVersion: number) { return users.update(id, { name }, expectedVersion); // Throws if version mismatch (concurrent update detected) }, // Smart save (insert or update) async saveUser(user: Partial>) { return users.save(user); }, }), }) {} ``` Deleting Entities deleting.tsTypeScript ```typescript import { User, UserRepository } from './user-model'; class UserService extends defineService({ inject: { users: UserRepository }, factory: ({ users }) => ({ // Delete by ID async deleteUser(id: string) { return users.delete(id); // Returns: boolean (true if deleted, false if not found) }, // Delete matching a condition async deleteInactive() { return users.deleteWhere(User.fields.status.eq('inactive')); // Returns: number (count of deleted rows) }, }), }) {} ``` Streaming Results For large result sets, use streaming to avoid loading everything into memory: streaming.tsTypeScript ```typescript import { User, UserRepository } from './user-model'; class UserService extends defineService({ inject: { users: UserRepository }, factory: ({ users }) => ({ // Stream entities one at a time async *processAllUsers() { for await (const user of users.stream()) { yield user; // Process each user without loading all into memory } }, // Stream in batches (more efficient for bulk operations) async processBatches() { for await (const batch of users.streamBatches({ batchSize: 100 })) { await processBatch(batch); // Process 100 users at a time } }, }), }) {} ``` PgModel Options When creating a PgModel, you can configure storage details: pg-model-options.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; import { createPgModel } from '@justscale/postgres'; class Product extends defineModel({ name: field.string().max(255), category: field.ref(() => Category), author: field.ref(() => User), tags: field.array(field.string()), status: field.enum('ProductStatus', ['draft', 'published'] as const), }) {} const PgProduct = createPgModel(Product, { table: 'products', storageMode: 'columnar', // 'columnar' | 'jsonb' // Override column types and constraints overrides: { name: { type: 'VARCHAR(255)', unique: false }, tags: { type: 'TEXT[]' }, }, // Configure foreign key behavior relations: { category: { onDelete: 'SET NULL' }, author: { onDelete: 'CASCADE' }, }, // Define indexes indexes: [ { fields: ['category', 'status'], name: 'idx_product_category_status' }, { fields: ['name'], using: 'gin', name: 'idx_product_name_search' }, ], }); ``` Connecting to PostgreSQL Supply the connection string through a secret provider and add PostgresFeature, then register your repositories: database-setup.tsTypeScript ```typescript import JustScale, { createSecretProvider } from '@justscale/core'; import { PostgresFeature, PostgresSecrets } from '@justscale/postgres'; import { UserRepository } from './user-model'; import { UserService } from './user-service'; const Secrets = createSecretProvider({ provides: [PostgresSecrets], factory: () => ({ [PostgresSecrets.key]: { connectionString: process.env.DATABASE_URL! }, }), }); const app = JustScale() .add(Secrets) .add(PostgresFeature) // provides AbstractPostgresClient .add(UserRepository) .add(UserService) .build(); await app.serve({ http: 3000 }); ``` See the PostgreSQL overview for channel/lock features, pool tuning via PostgresClientConfig, and the low-level @justscale/postgres/advanced escape hatch. Transactions The PostgreSQL client supports automatic transaction management with savepoints for nested transactions: transactions.tsTypeScript ```typescript import { AbstractPostgresClient } from '@justscale/postgres'; import { UserRepository } from './user-model'; class UserService extends defineService({ inject: { client: AbstractPostgresClient, users: UserRepository, }, factory: ({ client, users }) => ({ async transfer(from: Ref, to: Ref, amount: number) { // All operations in this transaction succeed or fail together await client.transaction(async () => { const fromUser = await users.get(from); const toUser = await users.get(to); if (!fromUser || !toUser) throw new Error('User not found'); if (fromUser.balance < amount) throw new Error('Insufficient funds'); await users.update(from, { balance: fromUser.balance - amount }); await users.update(to, { balance: toUser.balance + amount }); }); }, }), }) {} ``` ℹ️Info The PostgreSQL adapter uses AsyncLocalStorage to automatically apply the current transaction context to all queries. You do not need to pass a transaction object around. Nested Transactions (Savepoints) Nested transactions are automatically handled using PostgreSQL savepoints: nested-transactions.tsTypeScript ```typescript import { AbstractPostgresClient } from '@justscale/postgres'; class NestedTransactionService extends defineService({ inject: { client: AbstractPostgresClient, users: UserRepository }, factory: ({ client, users }) => ({ async complexOperation() { await client.transaction(async () => { await users.insert({ email: 'a@example.com', name: 'Alice' }); // Nested transaction = savepoint await client.transaction(async () => { await users.insert({ email: 'b@example.com', name: 'Bob' }); // If this throws, only Bob's insert is rolled back }); await users.insert({ email: 'c@example.com', name: 'Carol' }); }); }, }), }) {} ``` Transaction Isolation Levels Configure isolation levels for specific transactions: isolation-levels.tsTypeScript ```typescript import { AbstractPostgresClient } from '@justscale/postgres'; class IsolationService extends defineService({ inject: { client: AbstractPostgresClient }, factory: ({ client }) => ({ async criticalOperation() { await client.transaction(async () => { // Transaction logic here }, { isolationLevel: 'serializable' }); // Options: 'read uncommitted', 'read committed', 'repeatable read', 'serializable' }, }), }) {} ``` After Commit Hooks Register callbacks to run after a transaction commits: after-commit.tsTypeScript ```typescript import { AbstractPostgresClient } from '@justscale/postgres'; class AfterCommitService extends defineService({ inject: { client: AbstractPostgresClient, users: UserRepository }, factory: ({ client, users }) => ({ async createUser(email: string, name: string) { await client.transaction(async () => { const user = await users.insert({ email, name }); // Send email only if transaction commits client.afterCommit(async () => { await sendWelcomeEmail(user.email); }); }); }, }), }) {} ``` Identity Map The PostgreSQL client includes an identity map for entity caching within transactions: identity-map.tsTypeScript ```typescript import { AbstractPostgresClient } from '@justscale/postgres'; class IdentityMapService extends defineService({ inject: { client: AbstractPostgresClient, users: UserRepository }, factory: ({ client, users }) => ({ async example() { await client.transaction(async () => { const ref = User.ref`123`; const user1 = await users.get(ref); const user2 = await users.get(ref); // user1 === user2 (same object instance from identity map) // Clear cache if needed client.clearIdentityMap(); }); }, }), }) {} ``` Best Practices Use transactions for multi-step operations - Ensure data consistency when multiple queries must succeed or fail together Leverage streaming for large datasets - Avoid memory issues by processing entities one at a time or in batches Configure indexes appropriately - Add indexes for frequently queried fields, but avoid over-indexing Use optimistic locking - Pass expectedVersion to update/delete methods to detect concurrent modifications Use afterCommit hooks - For side effects like sending emails or publishing events that should only occur on successful commit Next Steps Migrations Queries Overview References --- # Process Compiler URL: https://justscale.sh/docs/processes/compiler Process Compiler How durable processes are transformed at build time The process compiler transforms your async handler functions into switch-based state machines at build time. This enables processes to suspend, persist their state, and resume from exactly where they left off - while keeping the generated code readable and debuggable. Compilation Pipeline When you run the build, createProcess() calls are transformed through several stages: Detection - Find all createProcess() calls in source files Analysis - Parse the handler to find suspension points (signals, delays, races) Step extraction - Each suspension point becomes a separate case in the switch Variable rewriting - Local variables become state.vars.xxx lookups Rehydration insertion - using declarations are re-fetched at each step Code generation - Output the compiled __createSwitchProcess call Before and After Here's what the compiler does to your code: Files forgot-password.process.tsforgot-password.process.js (compiled) forgot-password.process.tsforgot-password.process.js (compiled) forgot-password.process.tsTypeScript ```typescript import { createProcess, signal, race, delay } from '@justscale/core/process' import { UserService, NotificationService, AuthSignals } from './services' export const forgotPassword = createProcess({ path: '/auth/forgot-password/:email', inject: { users: UserService, notifications: NotificationService, signals: AuthSignals }, async handler({ users, notifications, signals }, [email]) { // 'using' marks values for rehydration on resume using user = await users.findByEmail(email) if (!user) { return { success: true, message: 'If account exists, email sent.' } } const resetToken = generateToken() await notifications.sendPasswordResetEmail(email, resetToken) // Race between signal and timeout - suspends here const r = race() switch (true) { case signal(r, signals.passwordResetVerified): await users.updatePassword(user.id, r.newPassword) return { success: true, message: 'Password reset.' } case delay.minutes(r, 15): return { success: false, error: 'timeout' } } }, }) ``` The State Machine Instead of opcodes, the compiler generates a while(true) + switch(step) state machine. Each suspension point becomes a separate case: Case 0 - Entry point, runs until first suspension Case 1, 2, ... - Resume points after each signal/timer Return Tuple The execute function returns [status, value]: return-values.tsTypeScript ```typescript // Status 0: Process completed with result __r[0] = 0 __r[1] = { success: true } break main_loop // Status 1: Process suspended, waiting for signal/timer __r[0] = 1 __r[1] = { race: state.vars.__raceBranches } break main_loop // The executor uses this to: // - status 0: Mark process as 'completed', resolve handle.wait() // - status 1: Mark process as 'suspended', subscribe to signals/timers ``` Rehydration Variables declared with using are non-serializable (like database entities with methods). The compiler ensures they're re-fetched at the start of each case: rehydration.tsTypeScript ```typescript // Source code: using user = await users.findByEmail(email) // In EVERY case that uses 'user': case 0: { user = await services.users.findByEmail(state.vars.email) __dispose[__dispose_i++] = user // Track for cleanup // ... rest of case } case 1: { // Re-fetch on resume! user = await services.users.findByEmail(state.vars.email) __dispose[__dispose_i++] = user // ... rest of case } ``` This ensures the user object is fresh after suspension, not stale serialized data. Race Handling The race() pattern compiles to branch setup and suspension: race-compilation.tsTypeScript ```typescript // Source: const r = race() switch (true) { case signal(r, signals.passwordResetVerified): return { verified: true, token: r.token } case delay.minutes(r, 15): return { expired: true } } // Compiled: state.vars.__raceBranches = [ { id: "signals.passwordResetVerified", signal: "...", resumeStep: 1 }, { id: "__timer__", timer: { minutes: 15 }, resumeStep: 2 } ] __r[0] = 1 __r[1] = { race: state.vars.__raceBranches } break main_loop // When signal fires, executor sets: state.vars.__raceResult = payload state.step = 1 // or 2 for timer // Then re-runs execute() ``` Variable Storage Local variables that cross suspension points are stored in state.vars: variable-storage.tsTypeScript ```typescript // Source: const resetToken = generateToken() const expiresAt = new Date(Date.now() + 15 * 60 * 1000) // After suspension, access via state.vars: state.vars.resetToken = generateToken() state.vars.expiresAt = new Date(Date.now() + 15 * 60 * 1000) // Variables NOT crossing suspensions stay as locals: const localTemp = compute() // Not stored in state ``` Metadata The compiled process includes metadata for debugging and versioning: metadata.tsTypeScript ```typescript { // Unique process identifier id: "auth_forgot-password__email", // Route pattern path: "/auth/forgot-password/:email", // Version hash - changes when suspension structure changes version: "v_9b6066ad", // Maps step IDs to case numbers (for debugging) stepMap: { "entry_4311c3e9": 0, "branch_747c6f71": 1, "branch_c496ea1b": 2 }, // Maps cases to source line numbers (for stack traces) sourceMap: { 0: [11, 26], // Entry spans lines 11-26 1: [21, 21], // Signal branch at line 21 2: [24, 24] // Timeout branch at line 24 }, // Signal definitions for runtime subscription signals: { "signals.passwordResetVerified": { identity: [], payloadType: "{ token: string; newPassword: string }" } } } ``` Dispose Pattern The compiler tracks using declarations and calls Symbol.dispose on cleanup: dispose.tsTypeScript ```typescript // Track disposables const __dispose = [undefined] let __dispose_i = 0 // When 'using' value is assigned user = await services.users.findByEmail(state.vars.email) __dispose[__dispose_i++] = user // After main_loop exits (completion or suspension) while (__dispose_i > 0) { __dispose?.[--__dispose_i]?.[Symbol.dispose]?.() } ``` Compile-Time Errors The compiler detects patterns that can't be safely suspended: errors.tsTypeScript ```typescript // ERROR: try/catch around suspension try { await signal(orders.paid) // Can't serialize exception state } catch (e) { } // ERROR: for-of loop with suspension for (const item of items) { await signal(item.processed) // Iterator not serializable } // ERROR: Promise.all with signals await Promise.all([signal(a), signal(b)]) // Use race() instead // ERROR: Storing signal in variable const sig = signal(orders.paid) // Must await directly await sig ``` Delay Syntax The compiler supports a cleaner delay syntax: delay-syntax.tsTypeScript ```typescript // New syntax - method on delay case delay.minutes(r, 15): case delay.hours(r, 24): case delay.days(r, 7): case delay.seconds(r, 30): // Also works standalone await delay.hours(24) await delay.minutes(5) ``` Version Hashing The version hash changes when the suspension structure changes: versioning.tsTypeScript ```typescript version: "v_9b6066ad" // Hash of suspension structure // Hash CHANGES when: // - Signal names change // - Suspension points added/removed // - Race branches change // - Step order changes // Hash does NOT change when: // - Code inside cases changes (logic, comments) // - Variable names change // - Non-suspending code changes ``` Version mismatches are detected at resume time, enabling migration strategies for in-flight processes. Runtime Execution At runtime, the executor manages the state machine: Calls execute(ctx) with current state and services On [0, result] - marks process complete, resolves handle.wait() On [1, {race: branches}] - persists state, subscribes to signals/timers When signal fires - sets state.step to resume step, re-runs execute() Uses sourceMap to provide meaningful stack traces Next Steps Durable Processes Signals Runtime & Testing --- # Durable Processes URL: https://justscale.sh/docs/processes/overview Durable Processes Long-running workflows that survive restarts Durable processes are async workflows that can suspend, persist their state to storage, and resume later - even after server restarts. They're ideal for multi-step business flows like order fulfillment, payment processing, or user onboarding. Why Durable Processes? Traditional async functions lose their state when the server restarts. A payment flow that waits for webhook confirmation would fail if the server restarts during the wait. Durable processes solve this by: Persisting state - Variables are saved to storage at suspension points Signal-based resumption - External events (webhooks, user actions) resume the flow Durable timers - Delays survive restarts and fire at the correct time Compile-time transformation - Your async code is transformed into a resumable state machine Creating a Process Use createProcess to define a durable process with a path pattern, dependencies, and handler function. The handler looks like regular async code but can suspend at specific points: order-fulfillment.tsTypeScript ```typescript import { createProcess, signal, race, delay } from '@justscale/core/process' import { Order } from './models' import { OrderSignals, ShippingSignals } from './signals' export const OrderFulfillment = createProcess({ // Path pattern with parameters — param names match model keys in .types() path: '/order/:order/fulfillment', types: { Order }, // :order → Ref in the handler // Dependencies injected via DI inject: { orders: OrderSignals, shipping: ShippingSignals, }, // Handler receives deps and typed path params async handler({ orders, shipping }, { order }) { // 'order' is Ref. Resolve it — used to read details, not mutate. const found = await order if (!found) return { status: 'failed', reason: 'Order not found' } const r = race() switch (true) { // SUSPENSION POINT: payment with 3-day timeout case signal(r, orders.paymentConfirmed): // r.order is Locked, r.txId is string (from the signal's .data) await shipping.dispatch(r.order, r.address) break case delay.days(r, 3): return { status: 'payment_timeout', order: found } } // SUSPENSION POINT: wait for delivery await signal(shipping.delivered) return { status: 'completed', order: found } }, }) ``` Signals Signals are the core suspension mechanism. They represent events that can be emitted from outside a process and awaited inside. When a process awaits a signal, it suspends and persists its state until the signal is emitted. Defining Signals Signal groups are declared with defineSignals. Each call to signal('/path') produces a routable, emit-able signal — path params matched by .types({ Model }) carry Locked, and .data() adds any extra payload fields. payment-signals.tsTypeScript ```typescript import { defineSignals } from '@justscale/core/process' import { Order, User } from './models' export class PaymentSignals extends defineSignals(signal => ({ // Emit: { order: Locked, txId: string, amount: number } received: signal('/payment/:order/received') .data<{ txId: string; amount: number }>() .types({ Order }), // No extra data — just the Locked entity failed: signal('/payment/:order/failed') .types({ Order }), // Multiple typed path params refunded: signal('/payment/:order/user/:user/refunded') .data<{ reason: string }>() .types({ Order, User }), })) {} ``` Waiting for Signals Inside a process handler, signal(racer, target) inside a switch(true) narrows the race result to the signal's payload. Without a racer, await signal(target) suspends until it fires. checkout-process.tsTypeScript ```typescript import { createProcess, signal, race } from '@justscale/core/process' import { Order } from './models' import { PaymentSignals } from './signals' export const CheckoutProcess = createProcess({ path: '/checkout/:order', types: { Order }, inject: { payments: PaymentSignals }, async handler({ payments }, { order }) { const r = race() switch (true) { case signal(r, payments.received): // r is narrowed: r.order → Locked, r.txId, r.amount return { success: true, txId: r.txId, amount: r.amount } case signal(r, payments.failed): // r.order → Locked return { success: false, order: r.order } } }, }) ``` Emitting Signals Emit from outside a process — webhook handlers, API endpoints, background jobs. Call the signal with a single object: typed path params take Lockedentities (the emitter holds the lock), plus any declared data fields. webhook-controller.tsTypeScript ```typescript import { createController, ModelRepository } from '@justscale/core' import { Post } from '@justscale/http' import { Order } from './models' import { PaymentSignals } from './signals' export const WebhookController = createController('/webhooks', { inject: { payments: PaymentSignals, orders: ModelRepository.of(Order), }, routes: ({ payments, orders }) => ({ stripePayment: Post('/stripe/payment') .handle(async ({ body, res }) => { // Lock the order before emitting — receivers get a valid Locked using order = await orders.lock(Order.ref(body.orderId)) if (!order) return res.status(404).end() await payments.received({ order, txId: body.transactionId, amount: body.amount, }) res.json({ received: true }) }), }), }) ``` Racing Signals Use race() to wait for the first of multiple signals or timers. The pattern usesswitch(true) with type guards for type-safe narrowing: payment-with-timeout.tsTypeScript ```typescript import { createProcess, signal, race, delay } from '@justscale/core/process' import { Order } from './models' import { PaymentSignals, OrderSignals } from './signals' export const PaymentWithTimeout = createProcess({ path: '/payment/:order/wait', types: { Order }, inject: { payments: PaymentSignals, orders: OrderSignals }, async handler({ payments, orders }, { order }) { // Race between payment, cancellation, and timeout const r = race() switch (true) { case signal(r, payments.received): // r.order → Locked, r.txId → string, r.amount → number return { status: 'paid', txId: r.txId, amount: r.amount } case signal(r, orders.cancelled): // r.order → Locked (no extra data on this signal) return { status: 'cancelled' } case delay.minutes(r, 30): // Timeout - 30 minute timer return { status: 'expired' } } }, }) ``` Racing in Loops Combine race patterns with loops for flows that need retry or resend logic: verification-with-resend.tsTypeScript ```typescript import { createProcess, signal, race, delay } from '@justscale/core/process' import { User } from './models' import { AuthSignals, EmailService } from './services' export const EmailVerification = createProcess({ path: '/verify/:user/email', types: { User }, inject: { auth: AuthSignals, email: EmailService }, async handler({ auth, email }, { user }) { let resendCount = 0 const maxResends = 3 // Send initial verification email (services take Ref — no raw IDs) await email.sendVerificationCode(user) while (resendCount < maxResends) { const r = race() switch (true) { case signal(r, auth.codeSubmitted): // r.user → Locked, r.code → string const valid = await auth.verifyCode(r.user, r.code) return { verified: valid, attempts: resendCount + 1 } case signal(r, auth.resendRequested): resendCount++ await email.sendVerificationCode(user) continue // Loop back case delay.minutes(r, 10): return { verified: false, reason: 'timeout' } } } return { verified: false, reason: 'max_resends_exceeded' } }, }) ``` The 'using' Keyword Non-serializable values (like database entities with methods, connections, etc.) can't be persisted. Use the using keyword to mark values that should be re-fetched when the process resumes: order-process.tsTypeScript ```typescript import { createProcess, signal } from '@justscale/core/process' import { ModelRepository } from '@justscale/core/models' import { Order } from './models' import { PaymentSignals } from './signals' export const OrderProcess = createProcess({ path: '/order/:order/process', types: { Order }, inject: { orders: ModelRepository.of(Order), payments: PaymentSignals, }, async handler({ orders, payments }, { order }) { // 'using' means: re-execute this expression on resume using current = await orders.lock(order) if (!current) return { success: false, reason: 'order_missing' } if (current.status === 'cancelled') { return { success: false, reason: 'order_cancelled' } } // Suspend here — lock is released before suspension (using block ends) await signal(payments.received) // After resume, 'current' is re-fetched and re-locked automatically // so we see (and can mutate) the latest state. return { success: true, finalStatus: current.status } }, }) ``` Under the hood, the compiler creates "rehydration blocks" that re-execute the using expressions when the process resumes from a suspension point. Starting Processes Process definitions are callable - pass the path parameters as a tuple to start an instance: order-controller.tsTypeScript ```typescript import { createController } from '@justscale/core' import { Post } from '@justscale/http' import { Order } from './models' import { OrderFulfillment } from './processes' import { OrderService } from './services' export const OrderController = createController('/orders', { inject: { orders: OrderService }, routes: ({ orders }) => ({ create: Post('/') .handle(async ({ body, res }) => { const order = await orders.create(body) // Start the fulfillment process with a typed ref — no raw IDs const handle = await OrderFulfillment([Order.ref(order)]) res.json({ order: Order.ref(order).identifier, // only at system boundaries processId: handle.id, status: handle.status, }) }), }), }) ``` Idempotent Starts Starting a process is idempotent - if a process with those parameters already exists, you get a handle to the existing one instead of creating a duplicate: idempotent-start.tsTypeScript ```typescript // First call creates the process — pass a Ref, not a raw string const handle1 = await OrderFulfillment([Order.ref('order-123')]) console.log(handle1.id) // 'order/order-123/fulfillment' // Second call with the same ref returns a handle to the same process const handle2 = await OrderFulfillment([Order.ref('order-123')]) console.log(handle2.id === handle1.id) // true // Wait for completion const result = await handle2.wait() console.log(result) // { status: 'completed', order: Persistent } ``` How It Works The JustScale compiler transforms your async handler into a switch-based state machine. This happens at build time, so there's no runtime overhead for parsing your code. Compilation Pipeline Analysis - The compiler scans your handler for suspension points (signal(), delay(), race()) Step extraction - Each suspension point becomes a separate case in the switch Variable rewriting - Variables that cross suspensions become state.vars.xxx lookups Rehydration insertion - using declarations are re-fetched at each step Runtime Execution At runtime, the executor manages the state machine: Case 0 - Entry point, runs until first suspension Case 1, 2, ... - Resume points after each signal/timer Return [0, result] - Process completed with result Return [1, {race: branches}] - Process suspended, waiting for signals When a signal is emitted, the executor sets the next step and re-runs the execute function from where it left off. Limitations Some patterns are not supported in process handlers because they can't be safely suspended: try/catch around suspensions - Can't serialize exception state for-of loops with suspensions - Iterator state isn't serializable Promise.all with signals - Use race() instead Nested async functions with signals - Keep suspension points in the main handler Storing signal() in variables - Must be awaited directly The compiler detects these patterns and provides helpful error messages. Best Practices Use signals for external events - Webhooks, user actions, scheduled events Keep handlers focused - One process per business flow, delegate to services Use 'using' for entities - Ensures fresh data after suspension Design for idempotency - Process paths should uniquely identify an instance Add timeouts - Use race() with delay() to prevent stuck processes Test with TestContainer - Mock the signal bus and timer scheduler for unit tests Next Steps Signals Runtime & Testing Compiler --- # Runtime & Testing URL: https://justscale.sh/docs/processes/runtime Runtime & Testing Setting up the process executor and testing durable processes Durable processes need a runtime to execute. The runtime consists of three pluggable components: storage (persists state), signal bus (routes signals), and timer scheduler (manages durable timers). In-Memory Runtime For development and testing, use the in-memory runtime. State is lost on restart, but it's fast and requires no external dependencies: setup.tsTypeScript ```typescript import { createInMemoryRuntime, setProcessExecutor } from '@justscale/core/process' import { container } from './container' // Create runtime with all components const runtime = createInMemoryRuntime({ container }) // Set as global executor (required for signal emission) setProcessExecutor(runtime.executor) // Now processes can be started and signals emitted import { OrderFulfillment } from './processes' const handle = await OrderFulfillment(['order-123']) console.log(handle.status) // 'pending' | 'running' | 'suspended' | 'completed' | 'failed' ``` Production Runtime For production, plug in persistent backends. JustScale provides PostgreSQL adapters: app.tsTypeScript ```typescript import { defineApp } from '@justscale/core' import JustScale from '@justscale/core' import { PostgresFeature, PostgresChannelFeature, PostgresLockFeature, PostgresProcessFeature, PostgresMigrationFeature, } from '@justscale/postgres' import type { AppEnv } from './env-contract.js' // defineApp handles env loading, build/compile, and CLI-vs-serve dispatch. // PostgresProcessFeature provides the process executor, signal bus, and // scheduled-task timers; it requires client + channel backend + lock // provider, which the three features above register. export default defineApp(import.meta, (env: AppEnv) => JustScale() .add(env) .add(PostgresFeature) .add(PostgresChannelFeature) .add(PostgresLockFeature) .add(PostgresProcessFeature) .add(PostgresMigrationFeature), ) ``` Process Handle Starting a process returns a handle with status info and a promise for the result: handle-api.tsTypeScript ```typescript import { OrderFulfillment } from './processes' // Start a process const handle = await OrderFulfillment(['order-123']) // Handle properties handle.id // 'order/order-123/fulfillment' (instance ID) handle.status // 'pending' | 'running' | 'suspended' | 'completed' | 'failed' // Wait for completion (blocks until process finishes) const result = await handle.wait() // result is typed based on your handler's return type // Check status without blocking if (handle.status === 'suspended') { console.log('Waiting for signal...') } // Idempotent starts - same params returns existing handle const handle2 = await OrderFulfillment(['order-123']) console.log(handle.id === handle2.id) // true ``` Process Lifecycle Processes move through these states: pending - Created but not yet executing running - Currently executing blocks suspended - Waiting for signal or timer completed - Finished successfully with result failed - Threw an unrecoverable error, captured in state lifecycle.tsTypeScript ```typescript // pending → running (start executing) const handle = await MyProcess(['id']) // status: 'pending' → 'running' // running → suspended (hit signal/delay) await signal(service.someEvent) // status: 'suspended' // suspended → running (signal emitted) await service.someEvent('id', payload) // status: 'running' // running → completed (handler returns) return { success: true } // status: 'completed' // running → failed (handler throws) throw new Error('Oops') // status: 'failed' ``` Recoverable Errors (DoubleLockError) Not every handler error terminates the process. DoubleLockError — thrown when a handler tries to acquire a lock it already holds in the same async context — is treated as recoverable: the process stays suspended at its prior step with a lastError marker, so a future execution (next signal firing, or a post-deploy restart running fixed code) can retry from the same point. The rationale: durable processes outlive deploys. Terminating a multi-week process because of a bug that lands in the next PR would destroy state. Since using lock = await repo.lock(...) runs before mutations by design, a DoubleLockError throw never leaves the process in a half-mutated state — pausing is safe. recoverable-error.tsTypeScript ```typescript // Bug: handler locks the same entity twice in one pass async handler({ orders }, { order }) { using a = await orders.lock(order) using b = await orders.lock(order) // → DoubleLockError // mutations below never run } // Result: process state { status: 'suspended', // NOT 'failed' — recoverable error: undefined, // terminal-error slot untouched lastError: 'Cannot acquire lock "lock:Order:abc" — ... ' + '(process orders/... step 0)', lastErrorAt: Date } // Ship a fix, redeploy. On next signal firing OR restart, // executor re-runs handler with new code. If it now succeeds, // lastError / lastErrorAt are cleared and the process advances. ``` The error is still loud: the executor logs it at ERROR level with [ProcessExecutor] prefix and the full process/step context, so monitoring picks it up. Only the persistence behavior differs from a terminal failure. Introspecting lastError introspect.tsTypeScript ```typescript // Query suspended processes with a recoverable-error marker for await (const state of executor.queryByStatus('suspended')) { if (state.lastError) { console.warn( `Stuck process ${state.instanceId}: ${state.lastError} ` + `(since ${state.lastErrorAt?.toISOString()})` ) } } ``` Only DoubleLockError gets this treatment. Any other thrown error still transitions the process to failed with the terminal error field set. Duration Helpers delay exposes unit-named functions rather than a separate seconds()/minutes() helper. In a race, the first argument is the race() handle; the second is the numeric duration. durations.tsTypeScript ```typescript import { delay, race, signal } from '@justscale/core/process' // Standalone (outside a race) — await the suspension directly await delay.seconds(30) await delay.minutes(5) await delay.hours(24) await delay.days(7) // In a race — first arg is the race handle const r = race() switch (true) { case signal(r, orders.shipped): return { shipped: true } case delay.days(r, 30): return { expired: true, reason: '30-day shipping window exceeded' } } ``` Testing Processes The in-memory runtime provides direct access to components for testing: process.test.tsTypeScript ```typescript import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert' import { createInMemoryRuntime, setProcessExecutor } from '@justscale/core/process' import { TestContainer } from '@justscale/testing' import { OrderFulfillment } from './processes' import { OrderService, ShippingService } from './services' describe('OrderFulfillment', () => { let runtime: ReturnType let container: TestContainer beforeEach(() => { container = new TestContainer() container.register(OrderService, mockOrderService) container.register(ShippingService, mockShippingService) runtime = createInMemoryRuntime({ container }) setProcessExecutor(runtime.executor) }) afterEach(() => { runtime.stop() runtime.clear() }) it('completes when all signals received', async () => { // Start process const handle = await OrderFulfillment(['order-123']) assert.strictEqual(handle.status, 'suspended') // Emit payment signal await runtime.executor.emit('orders.paymentReceived', { orderId: 'order-123' }, { txId: 'tx-456' } ) // Check suspended again (waiting for shipped) const state = await runtime.storage.load(handle.id) assert.strictEqual(state?.status, 'suspended') // Emit shipped signal await runtime.executor.emit('orders.shipped', { orderId: 'order-123' }, { trackingNumber: 'TRACK-789' } ) // Wait for completion const result = await handle.wait() assert.deepStrictEqual(result, { status: 'completed', orderId: 'order-123', }) }) }) ``` Testing Timeouts The in-memory timer scheduler supports time manipulation for testing: timeout.test.tsTypeScript ```typescript it('expires after timeout', async () => { const handle = await PaymentProcess(['pay-123']) assert.strictEqual(handle.status, 'suspended') // Advance time past the timeout const futureDate = new Date(Date.now() + 25 * 60 * 60 * 1000) // +25 hours runtime.timerScheduler.advanceTo(futureDate) // Process should complete with timeout result const result = await handle.wait() assert.strictEqual(result.status, 'timeout') }) ``` Error Handling When a block throws, the process is marked as failed and the error is captured: error-handling.tsTypeScript ```typescript // Process that might fail export const RiskyProcess = createProcess({ path: '/risky/:id', inject: { service: RiskyService }, async handler({ service }, [id]) { // If this throws, process goes to 'failed' status const result = await service.doRiskyThing(id) return { success: true, result } }, }) // In your code const handle = await RiskyProcess(['123']) try { const result = await handle.wait() console.log('Success:', result) } catch (error) { // Error from the process console.error('Process failed:', error.message) } // Check state directly const state = await runtime.storage.load(handle.id) if (state?.status === 'failed') { console.log('Error:', state.error) } ``` Scoped Executor Use withExecutor to scope an executor to a specific code block (useful for tests or multi-tenant scenarios): scoped.tsTypeScript ```typescript import { withExecutor, createInMemoryRuntime } from '@justscale/core/process' // Create isolated runtime for this scope const runtime = createInMemoryRuntime({ container }) await withExecutor(runtime.executor, async () => { // All process operations in here use this executor const handle = await MyProcess(['id']) await service.emitSignal('id', payload) const result = await handle.wait() }) // Outside the scope, the previous executor is restored ``` Storage Interface Implement ProcessStorage to use custom backends: custom-storage.tsTypeScript ```typescript import type { ProcessStorage, ProcessState, ProcessStatus } from '@justscale/core/process' class RedisProcessStorage implements ProcessStorage { constructor(private redis: RedisClient) {} async save(state: ProcessState): Promise { await this.redis.set(`process:${state.instanceId}`, JSON.stringify(state)) } async load(instanceId: string): Promise { const data = await this.redis.get(`process:${instanceId}`) return data ? JSON.parse(data) : null } async delete(instanceId: string): Promise { await this.redis.del(`process:${instanceId}`) } async complete(instanceId: string, result: unknown): Promise { const state = await this.load(instanceId) if (state) { state.status = 'completed' state.result = result await this.save(state) } } // ... other methods } ``` Next Steps Signals Compiler Testing --- # Signals URL: https://justscale.sh/docs/processes/signals Signals Event-driven suspension and resumption of durable processes Signals are typed events that suspend a durable process until they're emitted. They enable long-running workflows to wait for external events like webhook callbacks, user actions, or scheduled triggers. Signals are declared with defineSignals, a class factory that groups related signals and makes them injectable. Defining Signals Each signal is a signal('/path') inside a defineSignals factory. Path params become the routing identity. Attach model types with .types({ Model }) so matching params carry Locked at emit/receive time. Add extra payload fields with .data(). Files srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts src/order-signals.tsTypeScript ```typescript import { defineSignals } from '@justscale/core/process'; import { Order, User } from './models'; /** * Signal groups are injectable services. defineSignals wires the executor * internally — you never see createSignal or AbstractProcessExecutor. */ export class OrderSignals extends defineSignals((signal) => ({ // :order matches Order in .types() → r.order is Locked paymentReceived: signal('/order/:order/payment/received') .data<{ txId: string; amount: number }>() .types({ Order }), // No extra data — just the typed entity delivered: signal('/order/:order/delivered') .types({ Order }), // Multiple typed path params itemReturned: signal('/order/:order/user/:user/item-returned') .data<{ reason: string }>() .types({ Order, User }), // String identity (path param not in .types() stays as string) batchProcessed: signal('/order/batch/:batchId') .data<{ count: number }>(), })) {} ``` What each piece does Path — defines the routing namespace. Params are extracted and used as the signal's identity (subscribers with matching identity wake up). .types({ Model }) — path param keys matched against model class names (direct, lowercased, or lowercased + "Ref") become Locked in emit/receive. .data() — declares extra payload fields merged into the emit argument and the received race result. Unmatched path params — stay as string. Useful for opaque IDs that don't correspond to a model. Registering the Signal Group Because defineSignals returns a class, you register it in the builder like any other service and inject it into processes, services, or controllers: bootstrap.tsTypeScript ```typescript JustScale() .add(InMemoryLockFeature) .add(InMemoryProcessFeature) .add(OrderSignals) // Register the signal group .add(OrderFulfillment) // Process that uses it .build() ``` Waiting for Signals Inside a process handler, race multiple signals with race() + switch(true). The signal(r, target) case narrows r to that signal's payload (typed path params as Locked, plus data fields). Files srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts src/fulfillment.process.tsTypeScript ```typescript import { createProcess, signal, race, delay } from '@justscale/core/process'; import { Order } from './models'; import { OrderSignals } from './order-signals'; export const FulfillmentProcess = createProcess({ path: '/order/:order/fulfillment', types: { Order }, // :order → Ref inject: { orders: OrderSignals }, async handler({ orders }, { order }) { const r = race(); switch (true) { case signal(r, orders.paymentReceived): // r.order → Locked // r.txId → string, r.amount → number break; case delay.days(r, 3): return { status: 'payment_timeout' as const, order }; } // Subsequent suspension — await form (no race, single signal) await signal(orders.delivered); return { status: 'delivered' as const }; }, }); ``` 💡Tip Even though the process's own types: { Order } only gives Ref, a signal's typed path params arrive as Locked. The lock was acquired by the emitter and the receiver gets proof of it — no re-locking required inside the case block. Emitting Signals Signals are callable with a single object argument. Typed path params take Locked entities — the emitter must hold a lock on each, and the lock travels through to any receiver. Data fields are supplied alongside. Files srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts src/webhook.controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { ModelRepository } from '@justscale/core/models'; import { Post } from '@justscale/http'; import { z } from 'zod'; import { Order } from './models'; import { OrderSignals } from './order-signals'; const StripePayload = z.object({ type: z.string(), orderId: z.string(), transactionId: z.string(), amount: z.number(), }); export const StripeWebhookController = createController('/webhooks/stripe', { inject: { orders: OrderSignals, orderRepo: ModelRepository.of(Order), }, routes: ({ orders, orderRepo }) => ({ paymentIntent: Post('/payment-intent') .body(StripePayload) .handle(async ({ body, res }) => { // Lock the order before emitting — the receiver will see Locked. using order = await orderRepo.lock(Order.ref(body.orderId)); if (!order) { res.status(404).end(); return; } if (body.type === 'payment_intent.succeeded') { await orders.paymentReceived({ order, txId: body.transactionId, amount: body.amount, }); } res.json({ received: true }); }), }), }); ``` Signal Routing When a signal fires, the runtime matches it against subscribed processes by identity — each path param of the signal is compared to the identity of each waiting process. Only processes with all path params matching receive it: routing-example.tsTypeScript ```typescript // Start two processes, each with a different order ref await FulfillmentProcess([Order.ref('order-123')]) await FulfillmentProcess([Order.ref('order-456')]) // Emit for order-123 — the identity { order: 'order-123' } routes // only to the first process. using order123 = await orderRepo.lock(Order.ref('order-123')) await orders.paymentReceived({ order: order123!, txId: 'tx_abc', amount: 100 }) // → first process resumes, second keeps waiting ``` Multi-param routing Signals with multiple typed params must have all of them match the subscribing process's identity. Good for scoping returns to a specific item in an order, a specific user in a tenant, etc. Signal vs Direct Await The signal() wrapper is required for the compiler to recognize suspension points. Directly awaiting a signal object throws at runtime. signal-usage.tsTypeScript ```typescript async handler({ orders }, { order }) { const r = race() switch (true) { // CORRECT: signal(r, target) in a switch-true case case signal(r, orders.paymentReceived): return { paid: true } } // CORRECT: await signal(target) — single-signal suspension await signal(orders.delivered) // WRONG: direct await throws // await orders.paymentReceived // runtime error // WRONG: stashing the wrapper // const s = signal(orders.paymentReceived) // cannot be awaited later } ``` Type Safety The builder chain carries types end to end: emitters are checked against the declared shape, and the switch(true) narrowing picks up exactly what the signal delivers. type-safety.tsTypeScript ```typescript class PaymentSignals extends defineSignals(signal => ({ received: signal('/payment/:order/received') .data<{ txId: string; amount: number }>() .types({ Order }), })) {} // Emitting — type-checked: // Error: 'order' must be Locked, not a string await svc.received({ order: 'order-123', txId: 't', amount: 1 }) // Error: missing required 'amount' field await svc.received({ order: locked, txId: 't' }) // Error: 'amount' is number, not string await svc.received({ order: locked, txId: 't', amount: '1' }) // In a race — narrowed by switch(true) + signal(r, target): case signal(r, svc.received): r.order // Locked r.txId // string r.amount // number r.other // Error: property 'other' does not exist ``` Best Practices Name paths, not events — /order/:order/shippedreads better than orders.shipped and encodes routing identity. One signal group per domain — group related signals into a single defineSignals class (e.g. OrderSignals, PaymentSignals). Lock before emit — if a signal has a typed path param, the emitter must using x = await repo.lock(ref) beforehand. Receivers rely on that lock. Keep payloads serializable — data fields (from .data()) must round-trip through storage; stick to primitives, arrays, plain objects, and declared Processable types. Emit for the identity you want — re-emitting a signal is safe; only waiting processes with matching identity will resume. Next Steps Runtime & Testing Compiler Services --- # Query Conditions Reference URL: https://justscale.sh/docs/queries/conditions Query Conditions Reference Complete reference of all query operators and conditions JustScale's query system provides type-safe operators for every field type. Each field expression exposes only the operators that make sense for its type, ensuring compile-time safety. Comparison Operators Available on all fields for equality checks: .eq(value) - Equal eq-example.tsTypeScript ```typescript import { Product } from './models'; // Equal to value const active = await productRepo.find({ where: Product.fields.status.eq('active'), }); // Works with all field types Product.fields.price.eq('99.99'); // decimal Product.fields.stock.eq(10); // int Product.fields.featured.eq(true); // boolean ``` .neq(value) - Not Equal neq-example.tsTypeScript ```typescript import { Product } from './models'; // Not equal to value const notArchived = await productRepo.find({ where: Product.fields.status.neq('archived'), }); ``` Numeric Operators Available on numeric fields (int, decimal, float, bigint): numeric-operators.tsTypeScript ```typescript import { Product } from './models'; // Greater than Product.fields.price.gt(50); // Greater than or equal Product.fields.price.gte(50); // Less than Product.fields.stock.lt(10); // Less than or equal Product.fields.stock.lte(10); // Between (inclusive) Product.fields.price.between(10, 100); // In list Product.fields.stock.in([0, 5, 10, 20]); // Not in list Product.fields.price.notIn(['0.00', '9.99']); ``` String Operators Available on string and text fields: string-operators.tsTypeScript ```typescript import { Product } from './models'; // LIKE with wildcards (% matches any characters) Product.fields.name.like('%phone%'); // Case-insensitive LIKE Product.fields.name.ilike('%PHONE%'); // Matches "iPhone", "Phone", "phone" // Starts with prefix Product.fields.name.startsWith('Apple'); // Ends with suffix Product.fields.name.endsWith('Pro'); // Contains substring (wraps with %) Product.fields.name.contains('phone'); // Same as ilike('%phone%') // In list Product.fields.category.in(['Electronics', 'Computers']); // Not in list Product.fields.category.notIn(['Archived', 'Discontinued']); ``` Date and Time Operators Available on timestamp and date fields: date-operators.tsTypeScript ```typescript import { Product } from './models'; const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const today = new Date(); // Before date Product.fields.createdAt.before(lastWeek); // After date Product.fields.createdAt.after(lastWeek); // Between dates (inclusive) Product.fields.createdAt.between(lastWeek, today); // Also supports gt, gte, lt, lte Product.fields.updatedAt.gt(lastWeek); Product.fields.updatedAt.gte(lastWeek); Product.fields.updatedAt.lt(today); Product.fields.updatedAt.lte(today); ``` Array Operators Available on array fields: array-operators.tsTypeScript ```typescript import { Product } from './models'; // Array contains single element Product.fields.tags.contains('featured'); // Array has any of values Product.fields.tags.hasAny(['sale', 'new', 'featured']); // Array has all values Product.fields.tags.hasAll(['electronics', 'smartphone']); // Array overlaps with values Product.fields.tags.overlaps(['sale', 'clearance']); ``` Reference Operators Available on reference fields (ref and refs): reference-operators.tsTypeScript ```typescript import { q } from '@justscale/core/models'; import { Product, Category } from './models'; // Equal to reference (accepts Reference, entity with id, or id string) Product.fields.category.eq(categoryRef); Product.fields.category.eq(category); // Entity with id Product.fields.category.eq('category-id'); // Not equal Product.fields.category.neq('category-id'); // In list Product.fields.category.in([cat1, cat2, 'cat-id-3']); // Has related entity matching condition (JOIN) Product.fields.category.has( Category.fields.name.eq('Electronics') ); // Many-to-many refs Product.fields.tags.hasAny([tag1, tag2]); Product.fields.tags.hasAll([tag1, tag2]); ``` Null Checks Available on all fields to check for null values: null-checks.tsTypeScript ```typescript import { Product } from './models'; // IS NULL Product.fields.deletedAt.isNull(); // IS NOT NULL Product.fields.deletedAt.isNotNull(); // Also works with references Product.fields.category.isNull(); // No category assigned Product.fields.category.isNotNull(); // Has a category ``` Logical Operators Combine conditions using the q namespace: logical-operators.tsTypeScript ```typescript import { q } from '@justscale/core/models'; import { Product } from './models'; // AND - All conditions must match q.and( Product.fields.status.eq('active'), Product.fields.price.gte(10), Product.fields.stock.gt(0), ); // OR - Any condition must match q.or( Product.fields.status.eq('sale'), Product.fields.featured.eq(true), ); // NOT - Negate condition q.not( Product.fields.status.eq('archived'), ); // Combine logical operators q.and( Product.fields.status.eq('active'), q.or( Product.fields.price.lt(20), Product.fields.featured.eq(true), ), ); ``` has() for Relationships Query based on related entities using has(): has-operator.tsTypeScript ```typescript import { q } from '@justscale/core/models'; import { Order, OrderItem, Product } from './models'; // Find orders that have items from a specific category const orders = await orderRepo.find({ where: q.has( Order.fields.items, OrderItem.fields.product.has( Product.fields.category.eq('Electronics') ) ), }); // Find products in active categories const products = await productRepo.find({ where: Product.fields.category.has( Category.fields.status.eq('active') ), }); ``` Boolean Field Helpers Boolean fields have convenience methods: boolean-helpers.tsTypeScript ```typescript import { Product } from './models'; // Is true Product.fields.featured.isTrue(); // Same as .eq(true) // Is false Product.fields.featured.isFalse(); // Same as .eq(false) // In list Product.fields.active.in([true]); ``` Order By Order results using field methods or object syntax: order-by.tsTypeScript ```typescript import { Product } from './models'; // Fluent syntax await productRepo.find({ orderBy: Product.fields.price.asc(), }); await productRepo.find({ orderBy: Product.fields.price.desc(), }); // Object syntax await productRepo.find({ orderBy: { price: 'asc' }, }); // Multiple fields await productRepo.find({ orderBy: [ { status: 'desc' }, { price: 'asc' }, ], }); // Null ordering Product.fields.deletedAt.asc('first'); // Nulls first Product.fields.deletedAt.desc('last'); // Nulls last ``` Aggregations Perform aggregations using the q namespace: aggregations.tsTypeScript ```typescript import { q } from '@justscale/core/models'; import { Product } from './models'; // Count records const total = await productRepo.aggregate(q.count()); // Average const avgPrice = await productRepo.aggregate( q.avg(Product.fields.price) ); // Sum const totalValue = await productRepo.aggregate( q.sum(Product.fields.stock) ); // Min/Max const minPrice = await productRepo.aggregate(q.min(Product.fields.price)); const maxPrice = await productRepo.aggregate(q.max(Product.fields.price)); ``` Raw SQL Escape Hatch For complex queries not covered by the type-safe API, use q.raw(): raw-sql.tsTypeScript ```typescript import { q } from '@justscale/core/models'; // Raw SQL condition (use sparingly) const results = await productRepo.find({ where: q.and( Product.fields.status.eq('active'), q.raw('price * stock > ?', [1000]), ), }); ``` Next Steps Queries Overview Repositories Overview --- # Queries URL: https://justscale.sh/docs/queries/overview Queries Type-safe query system for building database queries JustScale provides a type-safe query system through field expressions. Instead of writing raw SQL or using string-based query builders, you access fields through Model.fields.fieldName and chain type-safe operators that match your schema. Field Expressions Every model exposes a .fields property that provides type-safe query expressions for each field. These expressions know what operations are valid based on the field type. product.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; export class Product extends defineModel({ name: 'Product', fields: { name: field.string().max(255), price: field.decimal(10, 2), status: field.enum('ProductStatus', ['draft', 'active', 'archived'] as const), stock: field.int(), }, }) {} // Access field expressions via Model.fields const { name, price, status, stock } = Product.fields; // Each field has operators matching its type const activeProducts = price.gte(10); // Numeric: gte, lte, gt, lt, between const draftStatus = status.eq('draft'); // Enum: knows valid values! const searchName = name.ilike('%phone%'); // String: like, ilike, startsWith const inStock = stock.gt(0); // Int: numeric operators ``` Basic Query Operations Use field expressions with repository methods to build type-safe queries: queries.tsTypeScript ```typescript import { q } from '@justscale/core/models'; import { Product } from './product'; import { productRepo } from './repositories'; // Simple equality const activeProducts = await productRepo.find({ where: Product.fields.status.eq('active'), }); // Comparison operators const affordableProducts = await productRepo.find({ where: Product.fields.price.lte(50), }); // String operations const searchResults = await productRepo.find({ where: Product.fields.name.ilike('%phone%'), orderBy: { price: 'asc' }, limit: 20, }); // Multiple conditions with q.and() const premiumInStock = await productRepo.find({ where: q.and( Product.fields.status.eq('active'), Product.fields.price.gte(100), Product.fields.stock.gt(0), ), }); ``` Logical Operators The q namespace provides logical operators to combine conditions: AND Use q.and() to require all conditions to match: and-example.tsTypeScript ```typescript import { q } from '@justscale/core/models'; import { Product } from './product'; // All conditions must be true const results = await productRepo.find({ where: q.and( Product.fields.status.eq('active'), Product.fields.price.between(10, 100), Product.fields.stock.gt(0), ), }); ``` OR Use q.or() to match any condition: or-example.tsTypeScript ```typescript import { q } from '@justscale/core/models'; import { Product } from './product'; // Any condition can be true const results = await productRepo.find({ where: q.or( Product.fields.status.eq('draft'), Product.fields.stock.eq(0), ), }); ``` NOT Use q.not() to negate a condition: not-example.tsTypeScript ```typescript import { q } from '@justscale/core/models'; import { Product } from './product'; // Negate a condition const results = await productRepo.find({ where: q.not( Product.fields.status.eq('archived'), ), }); ``` Ordering Results Order results using the orderBy option with field names and direction: ordering.tsTypeScript ```typescript import { Product } from './product'; // Simple ordering const byPrice = await productRepo.find({ orderBy: { price: 'asc' }, }); // Multiple fields const sorted = await productRepo.find({ orderBy: [ { status: 'desc' }, { price: 'asc' }, ], }); // Fluent syntax const fluent = await productRepo.find({ orderBy: Product.fields.price.desc(), }); ``` Pagination Use limit and offset for pagination: pagination.tsTypeScript ```typescript import { Product } from './product'; const page = 2; const pageSize = 20; const results = await productRepo.find({ where: Product.fields.status.eq('active'), orderBy: { createdAt: 'desc' }, limit: pageSize, offset: (page - 1) * pageSize, }); // Get total count for pagination const total = await productRepo.count({ where: Product.fields.status.eq('active'), }); ``` Type Safety The query system is fully type-safe. TypeScript catches invalid operations at compile time: type-safety.tsTypeScript ```typescript import { Product } from './product'; // Type-safe: enum values are checked Product.fields.status.eq('active'); // Valid Product.fields.status.eq('invalid'); // TypeScript error! // Type-safe: operators match field types Product.fields.price.gte(10); // Valid (numeric field) Product.fields.status.gte('active'); // TypeScript error! (enum has no gte) Product.fields.name.between(1, 10); // TypeScript error! (string has no between) ``` Next Steps Query Conditions Repositories Overview --- # In-Memory Repository URL: https://justscale.sh/docs/repositories/in-memory In-Memory Repository Fast, simple storage for testing and prototyping The In-Memory Repository provides a lightweight implementation of the Repository pattern backed by JavaScript Maps. It implements the same interface as the PostgreSQL adapter, making it perfect for testing and prototyping. Basic Usage Define a model with defineModel, then create an in-memory model and repository: user.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models' import { createInMemoryModel, createInMemoryRepository } from '@justscale/core/models' // 1. Domain model (same as production) export class User extends defineModel({ email: field.string().max(255).unique(), name: field.string().max(100), active: field.boolean().default(true), }) {} // 2. In-memory model (adapter decides storage details) const MemUser = createInMemoryModel(User) // 3. Repository for DI export const UserRepository = createInMemoryRepository(MemUser) ``` Querying The in-memory adapter supports the full repository query interface using type-safe field expressions: queries.tsTypeScript ```typescript // Get by reference const userRef = User.ref`${userId}` const user = await users.get(userRef) // Find one by field expression const john = await users.findOne(User.fields.email.eq('john@example.com')) // Find many with filters, ordering, pagination const activeUsers = await users.find({ where: User.fields.active.eq(true), orderBy: [User.fields.name.asc()], limit: 10, offset: 0, }) // Count const total = await users.count(User.fields.active.eq(true)) // Check existence const exists = await users.exists(User.fields.email.eq('test@example.com')) ``` Mutations insert() is the only mutation that does not require a lock — there's no prior row to protect. Every other write (update / save(existing) / delete) takes a Locked: the caller acquires the lock and passes proof in. See the Locks page for the rationale. mutations.tsTypeScript ```typescript // Insert — no lock needed (new row) const user = await users.insert({ email: 'alice@example.com', name: 'Alice' }) // Update — lock first, then update with the proof { using locked = await users.lock(user) if (locked) await users.update(locked, { name: 'Alice Smith' }) } // Lock via a typed ref (when you only have an id at a boundary) { using locked = await users.lock(User.ref(someId)) if (locked) await users.update(locked, { active: false }) } // Delete — also requires a lock { using locked = await users.lock(user) if (locked) await users.delete(locked) } // save() — insert for transient, update for Locked const newUser = new User({ email: 'bob@example.com', name: 'Bob' }) const saved = await users.save(newUser) // inserts (transient) { using locked = await users.lock(saved) if (locked) await users.save(locked) // updates (locked) } ``` Testing The in-memory adapter is ideal for unit tests. Use it as a drop-in replacement for the PostgreSQL adapter: user-service.test.tsTypeScript ```typescript import { test, expect, beforeEach } from 'node:test' import { createInMemoryModel, createInMemoryRepository } from '@justscale/core/models' import { User, UserRepository } from './user.model' import { UserService } from './user.service' import { TestContainer } from '@justscale/testing' test('creates user with unique email', async () => { const container = new TestContainer() container.bind(UserRepository, createInMemoryRepository(createInMemoryModel(User))) const service = container.resolve(UserService) const user = await service.create({ email: 'test@example.com', name: 'Test' }) expect(user.email).toBe('test@example.com') expect(user.name).toBe('Test') }) test('finds user by email', async () => { const container = new TestContainer() container.bind(UserRepository, createInMemoryRepository(createInMemoryModel(User))) const service = container.resolve(UserService) await service.create({ email: 'find@example.com', name: 'Find Me' }) const found = await service.findByEmail('find@example.com') expect(found?.name).toBe('Find Me') }) ``` Dependency Injection Wire in-memory repositories the same way as PostgreSQL — your services don't know the difference: app.tsTypeScript ```typescript import JustScale, { bindRepository } from '@justscale/core' import { ModelRepository, createInMemoryModel, createInMemoryRepository } from '@justscale/core/models' import { User } from './user.model' import { UserService } from './user.service' // In-memory for development const MemUser = createInMemoryModel(User) const InMemUserRepo = createInMemoryRepository(MemUser) const app = JustScale() .add(UserService) .add(bindRepository(ModelRepository.of(User), InMemUserRepo)) .build() ``` 💡Tip The service injects ModelRepository.of(User) — the abstract token. Whether you bind PostgreSQL or in-memory at bootstrap, the service code stays identical. Limitations ⚠️Warning The in-memory adapter has important limitations: No persistence — all data is lost when the process restarts Single process only — does not share data across instances Memory constrained — large datasets consume significant memory Simple queries only — no complex joins, aggregations, or full-text search For production applications, use the PostgreSQL adapter . Next Steps PostgreSQL Overview Testing References & ID-Free Domain --- # Repositories URL: https://justscale.sh/docs/repositories/overview Repositories Type-safe data access with the repository pattern Repositories provide a clean abstraction for data access in JustScale. They implement the repository pattern, separating your domain logic from storage details. This allows you to write business logic that works with any storage backend — PostgreSQL for production, in-memory for testing. The Modern Approach JustScale uses a layered approach: define your domain model once with defineModel, then wrap it with storage-specific configuration using createPgModel and createPgRepository: user.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models' import { createPgModel, createPgRepository } from '@justscale/postgres' // 1. Define domain model (pure, no storage details) export class User extends defineModel({ email: field.string().max(255).unique(), name: field.string().max(100), status: field.enum('UserStatus', ['active', 'inactive', 'banned'] as const) .default('active'), }) {} // 2. Create PostgreSQL storage model export const PgUser = createPgModel(User, { table: 'users', // Optional: defaults to snake_case pluralized }) // 3. Create repository service for DI export const UserRepository = createPgRepository(PgUser) ``` Why This Pattern? Clean Separation Your domain model (User) is pure TypeScript with no database dependencies. Storage configuration (PgUser) lives separately. Services only depend on the abstract repository interface. Type-Safe Queries Field expressions like User.fields.email.eq(value) are fully typed. TypeScript catches typos in field names and invalid comparison values at compile time: type-safe-queries.tsTypeScript ```typescript // TypeScript catches these errors: User.fields.email.eq(123) // Error: expected string User.fields.status.eq('unknown') // Error: not a valid status User.fields.emial.eq('test') // Error: 'emial' doesn't exist // Correct usage - fully typed: User.fields.email.eq('test@example.com') // OK User.fields.status.eq('active') // OK ``` Easy Testing Swap PostgreSQL for in-memory storage in tests without changing your services: user.service.test.tsTypeScript ```typescript import { createInMemoryRepository } from '@justscale/core/models' import { User, UserRepository } from './user.model' import { UserService } from './user.service' import { TestContainer } from '@justscale/testing' test('find by email', async () => { const container = new TestContainer() // Use in-memory repository for fast, isolated tests container.bind(UserRepository, createInMemoryRepository(User)) const service = container.resolve(UserService) await service.create({ email: 'test@example.com', name: 'Test' }) const user = await service.findByEmail('test@example.com') expect(user?.name).toBe('Test') }) ``` Repository Methods All repositories implement the same interface. Methods use Ref for entity lookups — not string IDs: repository-api.tsTypeScript ```typescript // Query methods await users.find({ where, orderBy, limit, offset }) // Find multiple await users.get(ref) // Get by reference await users.getMany(refs) // Batch get await users.findOne(where) // Find first match await users.count(where?) // Count matching await users.exists(where) // Check existence // Mutation methods await users.insert(data) // Insert one await users.insertMany([data1, data2]) // Bulk insert await users.update(ref, data) // Update by reference await users.save(entity) // Smart insert/update await users.delete(ref) // Delete by reference await users.deleteWhere(where) // Delete matching // References — not string IDs const userRef = User.ref`${userId}` // At boundaries await users.get(userRef) // Type-safe lookup await users.update(persistentUser, { name: 'New' }) // Entity IS a ref ``` 💡Tip A Persistent entity is itself a valid Ref. You can pass it directly to update(), delete(), or any method that accepts a reference. Wiring It Together Register your repository and services with the cluster builder. The PostgreSQL client connection is shared across all repositories: app.tsTypeScript ```typescript import JustScale, { defineApp } from '@justscale/core' import { PostgresFeature } from '@justscale/postgres' import type { AppEnv } from './env-contract' import { UserRepository } from './user.model' import { UserService } from './user.service' import { UserController } from './user.controller' // PostgresFeature reads its connection from the env contract; the app // doesn't construct a client by hand. `just dev` runs this; the // cluster socket and HTTP listener are wired by defineApp. export default defineApp(import.meta, (env: AppEnv) => JustScale() .add(env) .add(PostgresFeature) .add(UserRepository) .add(UserService) .add(UserController) ) ``` Available Adapters PostgreSQL Production-ready adapter with full query support, transactions, migrations, and advanced features like advisory locks and LISTEN/NOTIFY. Learn about PostgreSQL adapter In-Memory Fast, zero-dependency storage for testing and prototyping. Implements the full repository interface with JavaScript Maps. Learn about In-Memory Repository Next Steps References & ID-Free Domain Models Overview Queries Overview PostgreSQL Overview --- # References URL: https://justscale.sh/docs/repositories/references References Working with entity relationships in a DDD-friendly way References in JustScale allow you to work with entity relationships without exposing raw IDs. The repository resolves references — how it fetches the entity is an implementation detail. Defining References Use field.ref() to define a reference to another model: models.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models' class Author extends defineModel({ name: field.string().max(100), email: field.string().max(255).unique(), }) {} class Post extends defineModel({ title: field.string().max(200), status: field.enum('PostStatus', ['draft', 'published'] as const), author: field.ref(Author), // Reference, not a string ID }) {} ``` Creating References at Boundaries Raw strings only enter the system at boundaries — controllers, process handlers, external APIs. Convert them to typed references with tagged templates: creating-refs.tsTypeScript ```typescript import { Author, Post } from './models' // At a boundary: convert string to typed reference const authorRef = Author.ref`${authorId}` // Use in relationships const post = await postRepo.insert({ title: 'My Post', status: 'draft', author: authorRef, // Reference, not a raw string }) // A persistent entity IS a valid reference — just pass it directly const author = await authorRepo.findOne(Author.fields.email.eq('alice@example.com')) const post2 = await postRepo.insert({ title: 'Another Post', status: 'draft', author: author!, // Persistent works as Ref }) ``` Resolving References The repository is the abstraction for resolving references. Use get() with a typed reference: resolving-refs.tsTypeScript ```typescript // Get a single entity by reference const authorRef = Author.ref`${someId}` const author = await authorRepo.get(authorRef) // Batch get multiple references (single query) const refs = [Author.ref`${id1}`, Author.ref`${id2}`, Author.ref`${id3}`] const authors = await authorRepo.getMany(refs) // Resolve a reference from a loaded entity's field const post = await postRepo.findOne(Post.fields.title.eq('My Post')) const author = await post!.author // Reference is PromiseLike — just await ``` Why on the Repository? Repository is the contract for entity access Implementation is hidden (Postgres today, Redis tomorrow) You ask "give me this Author" and the repository figures out how IDs stay as an implementation detail of the storage layer Lazy Loading Reference fields loaded from the database are automatically awaitable. Just await them to fetch the related entity: lazy-loading.tsTypeScript ```typescript // Load a post const post = await postRepo.findOne(Post.fields.status.eq('published')) // The author field is a Reference — just await it const author = await post!.author console.log(author.name) // Or chain it const email = (await post!.author).email ``` Eager Loading For lists, lazy loading causes N+1 queries. Use the load option to batch-fetch references: eager-loading.tsTypeScript ```typescript // Without eager loading — N+1 queries! const posts = await postRepo.find({ where: Post.fields.status.eq('published'), }) for (const post of posts) { const author = await post.author // One query per post } // With eager loading — 2 queries total const posts = await postRepo.find({ where: Post.fields.status.eq('published'), load: ['author'], // Batch fetch all authors }) for (const post of posts) { const author = await post.author // Already loaded, no query } ``` Filtering on References Use .has() to filter on related entity fields, and combine with load for eager loading: has-with-load.tsTypeScript ```typescript // Find posts by premium authors AND eager load those authors const posts = await postRepo.find({ where: Post.fields.author.has( Author.fields.tier.eq('premium') ), load: ['author'], // Authors already filtered, now also loaded }) // All loaded — no additional queries for (const post of posts) { const author = await post.author console.log(`Premium author: ${author.name}`) } ``` How It Works Under the hood, eager loading: Main query fetches the primary entities Collects unique references from results Single batch query resolves all references Pre-populates Reference objects from the result Next Steps References & ID-Free Domain PostgreSQL Overview In-Memory Repository --- # RPC Client URL: https://justscale.sh/docs/rpc/client RPC Client Type-safe gRPC clients with DI integration The defineRpcClient function creates type-safe gRPC clients that integrate seamlessly with JustScale's dependency injection system. Clients automatically get all methods from the contract with full TypeScript inference. Basic Usage Define a client by specifying the contract and configuration source: TypeScript ```typescript import { defineRpcClient } from '@justscale/rpc' import { defineService } from '@justscale/core' import { GreeterContract } from './greeter.proto' // Settings service provides configuration class GreeterSettings extends defineService({ inject: {}, factory: () => ({ address: 'localhost:50051', timeout: 30_000, }), }) {} // Define the client with injected settings const GreeterClient = defineRpcClient({ contract: GreeterContract, inject: { settings: GreeterSettings }, }) ``` The client looks for a settings dependency with an address property by default. All methods from the contract become available on the client instance. Injecting Clients into Services Once defined, inject the client into your services like any other dependency: TypeScript ```typescript class OrderService extends defineService({ inject: { greeter: GreeterClient }, factory: ({ greeter }) => ({ async processOrder(customerId: string) { // Fully typed - TypeScript knows SayHello's input/output const reply = await greeter.SayHello({ name: customerId }) return reply.message }, async *streamUpdates(orderId: string) { // Server streaming methods return AsyncIterable for await (const update of greeter.StreamUpdates({ orderId })) { yield update } }, }), }) {} ``` Config Factory For more control over configuration, use the config factory function. This lets you combine multiple injected dependencies to build the client config: TypeScript ```typescript import { EnvService, ServiceDiscovery } from '@justscale/core' const GreeterClient = defineRpcClient({ contract: GreeterContract, inject: { env: EnvService, discovery: ServiceDiscovery, }, config: ({ env, discovery }) => ({ // Try service discovery first, fall back to env var address: discovery.resolve('greeter') ?? env.get('GREETER_ADDR'), timeout: Number(env.get('GREETER_TIMEOUT') ?? 30_000), metadata: { 'x-api-key': env.get('GREETER_API_KEY'), }, }), }) ``` The config factory receives all resolved dependencies and must return an RpcClientConfig object. Retry Configuration Configure automatic retries with exponential backoff for transient failures: TypeScript ```typescript const GreeterClient = defineRpcClient({ contract: GreeterContract, inject: { settings: GreeterSettings }, config: ({ settings }) => ({ address: settings.address, retry: { maxAttempts: 3, // Total attempts (default: 3) initialBackoff: 100, // Initial delay in ms (default: 100) maxBackoff: 10_000, // Maximum delay in ms (default: 10000) backoffMultiplier: 2, // Multiplier per retry (default: 2) retryableStatusCodes: [ // Which codes to retry 14, // UNAVAILABLE 8, // RESOURCE_EXHAUSTED ], }, }), }) ``` By default, only UNAVAILABLE and RESOURCE_EXHAUSTED status codes trigger retries. Failed retries use exponential backoff with jitter to avoid thundering herd. Load Balancing For high availability, configure a resolver and load balancer in the client config: TypeScript ```typescript import { defineRpcClient, staticResolver, roundRobinBalancer, } from '@justscale/rpc' import { GreeterContract } from './greeter.proto' const GreeterClient = defineRpcClient({ contract: GreeterContract, inject: {}, config: () => ({ // Logical name (passed to resolver) address: 'greeter-service', // Resolves the address to multiple endpoints resolver: staticResolver([ 'server1:50051', 'server2:50051', 'server3:50051', ]), // Distributes requests across endpoints loadBalancer: roundRobinBalancer(), }), }) ``` Available load balancers: TypeScript ```typescript import { roundRobinBalancer, randomBalancer, pickFirstBalancer, weightedRoundRobinBalancer, } from '@justscale/rpc' // Round-robin: cycle through addresses in order roundRobinBalancer() // Random: pick randomly with equal probability randomBalancer() // Pick-first: always use first address, failover on error pickFirstBalancer() // Weighted: distribute based on capacity weightedRoundRobinBalancer(new Map([ ['server1:50051', 3], // Gets 3x traffic ['server2:50051', 1], // Gets 1x traffic ])) ``` Available resolvers and custom resolver example: TypeScript ```typescript import { staticResolver, passthroughResolver } from '@justscale/rpc' // Static list of addresses staticResolver(['server1:50051', 'server2:50051']) // Pass-through for single server (uses address directly) passthroughResolver() // Custom DNS resolver const dnsResolver = { async resolve(target: string) { const addresses = await dns.resolve4(target) return addresses.map(ip => `${ip}:50051`) }, } ``` Load balancers receive success/failure feedback after each request, allowing intelligent routing decisions. The client maintains a connection pool, reusing HTTP/2 sessions for each endpoint. Full Configuration Reference All available options for RpcClientConfig: TypeScript ```typescript interface RpcClientConfig { // Server address (host:port) or logical name for resolver address: string // TLS configuration tls?: { ca?: Buffer | string // CA certificate cert?: Buffer | string // Client certificate key?: Buffer | string // Client private key insecure?: boolean // Skip verification (dev only!) } // Request timeout in ms (default: 30000) timeout?: number // Default metadata sent with every request metadata?: Record // Retry configuration retry?: { maxAttempts?: number // default: 3 initialBackoff?: number // default: 100ms maxBackoff?: number // default: 10000ms backoffMultiplier?: number // default: 2 retryableStatusCodes?: number[] // default: [UNAVAILABLE, RESOURCE_EXHAUSTED] } // Request compression (identity, gzip, deflate, none) compression?: 'identity' | 'gzip' | 'deflate' | 'none' // Message size limits (default: 4MB) maxReceiveMessageSize?: number maxSendMessageSize?: number // Address resolver for multi-server deployments resolver?: AddressResolver // Load balancer for distributing requests (default: round-robin) loadBalancer?: LoadBalancer } ``` Per-Call Options Override client defaults on a per-call basis: TypeScript ```typescript // Override timeout for a slow operation const reply = await greeter.SayHello( { name: 'World' }, { timeout: 60_000 } ) // Add request-specific metadata const reply = await greeter.SayHello( { name: 'World' }, { metadata: { 'x-request-id': requestId } } ) // Support cancellation via AbortSignal const controller = new AbortController() setTimeout(() => controller.abort(), 5000) const reply = await greeter.SayHello( { name: 'World' }, { signal: controller.signal } ) ``` Closing Connections Clean up client connections when done: TypeScript ```typescript // The client instance has a close() method await greeter.close() // In a service with cleanup class OrderService extends defineService({ inject: { greeter: GreeterClient }, factory: ({ greeter }) => ({ // ... methods ... async [Symbol.asyncDispose]() { await greeter.close() }, }), }) {} ``` Next Steps RPC Overview RPC Controllers Status Codes & Errors --- # RPC Controllers URL: https://justscale.sh/docs/rpc/controllers RPC Controllers Implementing gRPC services with contract-based controllers Contract controllers implement gRPC service definitions imported directly from .proto files. The TypeScript compiler plugin generates types at build time, so you get full type safety without codegen. Importing Contracts from Proto Files Import the generated contract directly from your .proto file. The compiler plugin handles type generation automatically: protobuf ```protobuf // greeter.proto syntax = "proto3"; package helloworld; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); rpc SayHelloStream (HelloRequest) returns (stream HelloReply); rpc RecordRoute (stream Point) returns (RouteSummary); rpc RouteChat (stream RouteNote) returns (stream RouteNote); } message HelloRequest { string name = 1; } message HelloReply { string message = 1; } ``` TypeScript ```typescript import { GreeterContract } from './greeter.proto' // The contract provides: // - GreeterContract: Service definition with method signatures // - HelloRequest, HelloReply: TypeScript types for messages // - Encode/decode functions for binary serialization ``` Creating a Contract Controller Use createController.implements(Contract) to create a controller that implements a gRPC service: TypeScript ```typescript import { createController } from '@justscale/core' import { GreeterContract } from './greeter.proto' const GreeterController = createController .implements(GreeterContract) .create({ inject: { db: DatabaseService }, methods: ({ db }) => ({ SayHello: async ({ body }) => ({ message: `Hello, ${body.name}!`, }), // All methods from the contract must be implemented SayHelloStream: async function* ({ body }) { for (let i = 0; i < 5; i++) { yield { message: `Hello ${body.name} #${i + 1}` } await delay(1000) } }, RecordRoute: async ({ body }) => { // body is AsyncIterable for client streaming let pointCount = 0 for await (const point of body) { pointCount++ } return { pointCount, distance: 0, elapsedTime: 0, featureCount: 0 } }, RouteChat: async function* ({ body }) { // body is AsyncIterable for bidi streaming for await (const note of body) { yield { location: note.location, message: `Echo: ${note.message}` } } }, }), }) ``` Streaming Modes gRPC supports four streaming modes. The method signature determines which mode is used: Unary - Single request, single response. Return a value or Promise. Server streaming - Single request, stream of responses. Return an async generator. Client streaming - Stream of requests, single response. body is an AsyncIterable. Bidirectional - Stream of requests, stream of responses. Combine both patterns. TypeScript ```typescript methods: () => ({ // Unary: request -> response SayHello: async ({ body }) => { return { message: `Hello, ${body.name}!` } }, // Server streaming: request -> stream of responses ListFeatures: async function* ({ body }) { const features = await db.features.findInArea(body.bounds) for (const feature of features) { yield feature } }, // Client streaming: stream of requests -> response RecordRoute: async ({ body }) => { const points: Point[] = [] for await (const point of body) { points.push(point) } return calculateRouteSummary(points) }, // Bidirectional: stream of requests -> stream of responses RouteChat: async function* ({ body }) { for await (const note of body) { const nearby = await db.notes.findNear(note.location) for (const n of nearby) { yield n } } }, }) ``` The RpcContext Object Every RPC handler receives an RpcContext with the following properties: TypeScript ```typescript interface interface RpcContextRpcContextTBody, function (type parameter) TSession in RpcContextTSession = unknown> { /** Request body (message for unary/server, AsyncIterable for client/bidi) */ RpcContext.body: TBodyRequest body (message for unary/server, AsyncIterable for client/bidi)body: function (type parameter) TBody in RpcContextTBody /** gRPC metadata (headers) */ RpcContext.metadata: MapgRPC metadata (headers)metadata: interface MapMap /** AbortSignal for cancellation */ RpcContext.signal: AbortSignalAbortSignal for cancellationsignal: AbortSignal /** Request deadline (if set by client) */ RpcContext.deadline?: DateRequest deadline (if set by client)deadline?: Date /** Session data (populated by auth middleware) */ RpcContext.session: TSession = unknownSession data (populated by auth middleware)session: function (type parameter) TSession in RpcContextTSession } ``` TypeScript ```typescript methods: () => ({ type SayHello: ({ body, metadata, signal, session }: { body: any; metadata: any; signal: any; session: any; }) => Promise<{ message: string; }> ``` SayHello: async ({ body: anybody, metadata: anymetadata, signal: anysignal, session: anysession }) => { // Access request message const const name: anyname = body: anybody.name // Read gRPC metadata (headers) const const traceId: anytraceId = metadata: anymetadata.get('x-trace-id') // Check for cancellation if (signal: anysignal.aborted) { throw new ``` var Error: ErrorConstructor new (message?: string, options?: ErrorOptions) => Error (+1 overload) ``` Error('Request cancelled') } // Access authenticated user (if auth middleware is used) const const userId: anyuserId = session: anysession?.userId return { message: stringmessage: `Hello, ${const name: anyname}!` } }, }) Handling Cancellation Use the signal property to handle client cancellation gracefully: TypeScript ```typescript ListFeatures: async function* ({ body, signal }) { const cursor = db.features.findInArea(body.bounds) for await (const feature of cursor) { // Check if client cancelled if (signal.aborted) { await cursor.close() return } yield feature } }, ``` Working with Metadata gRPC metadata is like HTTP headers. Access incoming metadata and set response metadata: TypeScript ```typescript methods: () => ({ SayHello: async ({ body, metadata }) => { // Read incoming metadata const authorization = metadata.get('authorization') const requestId = metadata.get('x-request-id') const clientVersion = metadata.get('x-client-version') // Validate authorization if (!authorization) { throw new GrpcError(GrpcStatus.UNAUTHENTICATED, 'Missing auth') } return { message: `Hello, ${body.name}!` } }, }) ``` Error Handling Use GrpcError to return proper gRPC status codes: TypeScript ```typescript import { GrpcError, GrpcStatus } from '@justscale/rpc' methods: () => ({ GetUser: async ({ body }) => { const user = await db.users.get(User.ref`${body.userId}`) if (!user) { throw new GrpcError( GrpcStatus.NOT_FOUND, `User ${body.userId} not found` ) } if (!user.active) { throw new GrpcError( GrpcStatus.PERMISSION_DENIED, 'User account is disabled' ) } return user }, }) ``` Common gRPC status codes: OK (0) - Success CANCELLED (1) - Operation cancelled by client INVALID_ARGUMENT (3) - Invalid request parameters NOT_FOUND (5) - Resource not found ALREADY_EXISTS (6) - Resource already exists PERMISSION_DENIED (7) - No permission UNAUTHENTICATED (16) - Missing/invalid credentials RESOURCE_EXHAUSTED (8) - Rate limit exceeded INTERNAL (13) - Internal server error Serving the Controller Add the controller to your JustScale app and serve via the RPC transport: TypeScript ```typescript import JustScale from '@justscale/core' import '@justscale/rpc' // Auto-registers RPC transport const app = JustScale() .add(GreeterController) .add(RouteGuideController) .build() await app.serve({ rpc: { port: 50051, enableReflection: true, // For grpcurl/grpcui enableHealthCheck: true, // For load balancer probes }, }) console.log('gRPC server running on port 50051') ``` Dependency Injection Contract controllers support full dependency injection. Inject services and use them in your method implementations: TypeScript ```typescript import { UserService } from './services/user.service' import { NotificationService } from './services/notification.service' import { AuditLogService } from './services/audit.service' const UserController = createController .implements(UserServiceContract) .create({ inject: { users: UserService, notifications: NotificationService, audit: AuditLogService, }, methods: ({ users, notifications, audit }) => ({ CreateUser: async ({ body, metadata }) => { const user = await users.create(body) // Send welcome notification await notifications.sendWelcome(user) // Log the action await audit.log('user.created', { userId: user.id, requestId: metadata.get('x-request-id'), }) return user }, GetUser: async ({ body }) => { return users.get(User.ref`${body.userId}`) }, }), }) ``` Type Safety The compiler ensures your implementation matches the contract exactly: TypeScript ```typescript // Proto definition service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); } // TypeScript catches errors at compile time: methods: () => ({ // Error: Method name must match proto definition sayHello: async ({ body }) => ({ ... }), // Should be 'SayHello' // Error: Return type must match HelloReply SayHello: async ({ body }) => ({ msg: 'Hello' // Error: Property 'message' is missing }), // Error: Input type must match HelloRequest SayHello: async ({ body }) => ({ message: body.username // Error: Property 'name' expected }), }) ``` Next Steps RPC Client Status Codes & Errors Guards --- # gRPC / RPC URL: https://justscale.sh/docs/rpc/overview gRPC / RPC Type-safe gRPC services with proto imports - no codegen required The @justscale/rpc package provides gRPC transport for contract-based controllers. Import contracts directly from .proto files - no code generation step required! Installation Bash ```bash pnpm add @justscale/rpc @justscale/protobuf ``` Key Features Direct proto imports - Import types directly from .proto files No codegen - TypeScript compiler plugin generates types at build time Contract-based - Define controllers that implement gRPC service contracts DI integration - Full dependency injection support for clients and servers All streaming modes - Unary, server streaming, client streaming, bidirectional Built-in features - Health checks, reflection, interceptors, compression Quick Example Define a proto file with your service contract: protobuf ```protobuf // greeter.proto syntax = "proto3"; package greeter; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); } message HelloRequest { string name = 1; } message HelloReply { string message = 1; } ``` Import and implement the contract in a controller: TypeScript ```typescript import { GreeterContract } from './greeter.proto' import { createController } from '@justscale/core' const GreeterController = createController .implements(GreeterContract) .create({ inject: {}, methods: () => ({ SayHello: async ({ body }) => ({ message: `Hello, ${body.name}!`, }), }), }) ``` Serve via cluster: TypeScript ```typescript import JustScale from '@justscale/core' import '@justscale/rpc' // Auto-registers transport const app = JustScale() .add(GreeterController) .build() await app.serve({ rpc: { port: 50051, enableReflection: true }, }) ``` Streaming RPCs All gRPC streaming modes are supported. Use async generators for streaming responses: TypeScript ```typescript // Server streaming async *StreamGreetings({ body }) { for (let i = 0; i < body.count; i++) { yield { message: `Hello #${i + 1}, ${body.name}!` } await delay(1000) } } // Bidirectional streaming async *Chat({ body: messages }) { for await (const msg of messages) { yield { reply: `Echo: ${msg.text}` } } } ``` Type-Safe Clients Create DI-integrated clients with defineRpcClient: TypeScript ```typescript import { defineRpcClient } from '@justscale/rpc' import { GreeterContract } from './greeter.proto' const GreeterClient = defineRpcClient({ contract: GreeterContract, inject: { settings: GreeterSettings }, }) // Use in services class MyService extends defineService({ inject: { greeter: GreeterClient }, factory: ({ greeter }) => ({ async greet(name: string) { const reply = await greeter.SayHello({ name }) return reply.message }, }), }) {} ``` Next Steps RPC Controllers RPC Client Status Codes & Errors --- # RPC Status Codes & Errors URL: https://justscale.sh/docs/rpc/status-errors RPC Status Codes & Errors gRPC status codes and error handling in JustScale The @justscale/rpc package provides a complete implementation of gRPC status codes and error handling. Throw typed errors in your handlers and they'll be properly transmitted to clients. GrpcStatus Enum All 17 standard gRPC status codes are available: CodeNameDescription 0OKNot an error; returned on success 1CANCELLEDOperation was cancelled by the caller 2UNKNOWNUnknown error 3INVALID_ARGUMENTClient specified an invalid argument 4DEADLINE_EXCEEDEDDeadline expired before operation completed 5NOT_FOUNDRequested entity was not found 6ALREADY_EXISTSEntity already exists 7PERMISSION_DENIEDCaller lacks permission 8RESOURCE_EXHAUSTEDResource has been exhausted (quota, etc.) 9FAILED_PRECONDITIONSystem not in required state 10ABORTEDOperation aborted (concurrency issue) 11OUT_OF_RANGEOperation attempted past valid range 12UNIMPLEMENTEDOperation not implemented or supported 13INTERNALInternal server error 14UNAVAILABLEService currently unavailable 15DATA_LOSSUnrecoverable data loss or corruption 16UNAUTHENTICATEDInvalid authentication credentials TypeScript ```typescript import { GrpcStatus } from '@justscale/rpc' // Access status codes GrpcStatus.OK // 0 GrpcStatus.NOT_FOUND // 5 GrpcStatus.INTERNAL // 13 GrpcStatus.UNAUTHENTICATED // 16 ``` GrpcError Class The GrpcError class represents gRPC errors with status codes. Throw these in your handlers and they'll be properly serialized and sent to clients. TypeScript ```typescript import { GrpcError, GrpcStatus } from '@justscale/rpc' // Basic error throw new GrpcError(GrpcStatus.NOT_FOUND, 'User not found') // With additional details throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, 'Invalid email format', { field: 'email', value: 'not-an-email', }) ``` GrpcError provides useful properties: code - The numeric gRPC status code message - Human-readable error message details - Optional additional error context statusName - Human-readable status name (e.g., "NOT_FOUND") TypeScript ```typescript const error = new GrpcError(GrpcStatus.NOT_FOUND, 'User not found') error.code // 5 error.message // "User not found" error.statusName // "NOT_FOUND" error.toString() // "GrpcError [NOT_FOUND]: User not found" ``` Error Helper Functions The grpcError helper provides factory functions for each status code, making error creation more readable: TypeScript ```typescript import { grpcError } from '@justscale/rpc' // Not found throw grpcError.notFound('User not found') // Invalid argument with details throw grpcError.invalidArgument('Email is invalid', { field: 'email' }) // Permission denied throw grpcError.permissionDenied('Admin access required') // Unauthenticated throw grpcError.unauthenticated('Token expired') // All available helpers: grpcError.cancelled(message, details?) grpcError.invalidArgument(message, details?) grpcError.deadlineExceeded(message, details?) grpcError.notFound(message, details?) grpcError.alreadyExists(message, details?) grpcError.permissionDenied(message, details?) grpcError.resourceExhausted(message, details?) grpcError.failedPrecondition(message, details?) grpcError.aborted(message, details?) grpcError.outOfRange(message, details?) grpcError.unimplemented(message, details?) grpcError.internal(message, details?) grpcError.unavailable(message, details?) grpcError.dataLoss(message, details?) grpcError.unauthenticated(message, details?) ``` Error Handling in Handlers Throw GrpcError in your RPC handlers. The framework catches them and sends the appropriate status code to clients: TypeScript ```typescript import { grpcError, isGrpcError } from '@justscale/rpc' const UserController = createController .implements(UserServiceContract) .create({ inject: { users: UserRepository }, methods: ({ users }) => ({ GetUser: async ({ body }) => { const user = await users.get(User.ref`${body.id}`) if (!user) { throw grpcError.notFound(`User ${body.id} not found`) } return user }, CreateUser: async ({ body }) => { const existing = await users.findByEmail(body.email) if (existing) { throw grpcError.alreadyExists('Email already registered') } return users.create(body) }, UpdateUser: async ({ body }) => { if (!body.id) { throw grpcError.invalidArgument('User ID is required') } // ... }, }), }) ``` Use isGrpcError to check if an error is a GrpcError: TypeScript ```typescript import { isGrpcError, GrpcStatus } from '@justscale/rpc' try { await client.GetUser({ id: 'unknown' }) } catch (err) { if (isGrpcError(err) && err.code === GrpcStatus.NOT_FOUND) { // Handle not found specifically } } ``` Automatic Error Mapping The errorToGrpcStatus function automatically maps common errors to appropriate gRPC status codes based on error message patterns: TypeScript ```typescript import { errorToGrpcStatus, GrpcError } from '@justscale/rpc' // Automatically maps error messages to status codes const error = new Error('User not found') const { code, message } = errorToGrpcStatus(error) // code = GrpcStatus.NOT_FOUND (5) // Pattern matching: // "not found", "does not exist" -> NOT_FOUND // "unauthorized", "invalid token" -> UNAUTHENTICATED // "permission denied", "forbidden" -> PERMISSION_DENIED // "already exists", "duplicate" -> ALREADY_EXISTS // "invalid", "validation" -> INVALID_ARGUMENT // "timeout", "deadline" -> DEADLINE_EXCEEDED // "cancelled", "aborted" -> CANCELLED // "unavailable", "connection refused" -> UNAVAILABLE // "exhausted", "rate limit", "quota" -> RESOURCE_EXHAUSTED // Everything else -> INTERNAL ``` You can also convert any error to a GrpcError using GrpcError.from(): TypeScript ```typescript import { GrpcError } from '@justscale/rpc' // Convert any error to GrpcError const anyError = new Error('Something went wrong') const grpcErr = GrpcError.from(anyError) // Returns GrpcError with INTERNAL status // GrpcErrors pass through unchanged const existing = grpcError.notFound('User not found') GrpcError.from(existing) === existing // true ``` Next Steps RPC Overview RPC Controllers RPC Client --- # Error Handling URL: https://justscale.sh/docs/techniques/error-handling Error Handling Handle errors with middleware and guards JustScale provides flexible error handling patterns through middleware, guards, and response helpers. Errors can be handled at multiple levels to create robust, maintainable applications. Response Error Helper The simplest way to send error responses is using res.error(): src/controllers/players.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { PlayerService } from '../services/player-service'; export const PlayersController = createController('/players', { inject: { players: PlayerService }, routes: (services) => ({ getOne: Get('/:id').handle(async ({ params, res }) => { const player = await services.players.get(Player.ref`${params.id}`); if (!player) { res.error('Player not found', 404); return; } res.json({ player }); }), }), }); ``` This sends: JSON ```json { "error": "Player not found" } ``` Validation Errors Validation middleware automatically handles errors: src/controllers/players.tsTypeScript ```typescript import { z } from 'zod'; import { createController } from '@justscale/core'; import { Post, body } from '@justscale/http/builder'; import { PlayerService } from '../services/player-service'; const CreatePlayerSchema = z.object({ name: z.string().min(1, 'Name is required'), chips: z.number().positive(), }); export const PlayersController = createController('/players', { inject: { players: PlayerService }, routes: (services) => ({ create: Post('/') .body(CreatePlayerSchema) .handle(async ({ body, res }) => { // If validation fails, parseBody calls res.error() and throws // This handler is never reached with invalid data const player = await services.players.save(body); res.json({ player }); }), }), }); ``` Invalid request returns: JSON ```json { "error": "Validation failed: Name is required" } ``` Middleware Error Handling Middleware can throw errors to stop the request: src/controllers/profile.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { requireAuth } from '../middleware/require-auth'; export const ProfileController = createController('/profile', { routes: () => ({ get: Get('/') .use(requireAuth) .handle(({ user, res }) => { // Only reached if authentication succeeds res.json({ user }); }), }), }); ``` Guards for Authorization Guards provide a cleaner pattern for authorization checks: src/controllers/profile.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { IsAuthenticated } from '../guards/is-authenticated'; export const ProfileController = createController('/profile', { routes: () => ({ get: Get('/') .guard(IsAuthenticated) .handle(({ user, res }) => { res.json({ user }); }), }), }); ``` Guard Composition Combine multiple guards: src/controllers/users.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Delete } from '@justscale/http'; import { IsAuthenticated } from '../guards/is-authenticated'; import { IsAdmin } from '../guards/is-admin'; import { UserService } from '../services/user-service'; export const UsersController = createController('/users', { inject: { users: UserService }, routes: (services) => ({ delete: Delete('/:id') .guard(IsAuthenticated) .guard(IsAdmin) .handle(async ({ params, res }) => { // Only admins can delete users await services.users.delete(User.ref`${params.id}`); res.json({ success: true }); }), }), }); ``` Service-Level Errors Throw domain-specific errors from services: src/controllers/games.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Post, populate } from '@justscale/http'; import { GameRepository } from '../repositories/game-repository'; import { GameService } from '../services/game-service'; import { requireAuth } from '../middleware/require-auth'; export const GamesController = createController('/games', { inject: { games: GameRepository, gameService: GameService }, routes: (services) => ({ join: Post('/:gameId/join') .use(populate(services.games, 'game', 'gameId', Game.ref)) .use(requireAuth) .handle(async ({ game, user, res }) => { try { await services.gameService.joinGame(Game.ref`${game}`, Player.ref`${user}`); res.json({ success: true }); } catch (error) { if (error.message === 'Game is full') { res.error('Cannot join: game is full', 409); } else if (error.message === 'Game already started') { res.error('Cannot join: game already started', 409); } else { res.error('Failed to join game', 500); } } }), }), }); ``` Custom Error Classes Create typed error classes for better error handling: src/controllers/players.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; import { PlayerService } from '../services/player-service'; import { errorHandler } from '../utils/error-handler'; export const PlayersController = createController('/players', { inject: { playerService: PlayerService }, routes: (services) => ({ getOne: Get('/:id').handle(async ({ params, res }) => { try { const player = await services.playerService.getPlayer(Player.ref`${params.id}`); res.json({ player }); } catch (error) { errorHandler(error, res); } }), }), }); ``` HTTP Status Codes Use appropriate status codes for different error types: 400 - Bad request (client error) 401 - Unauthorized (missing/invalid auth) 403 - Forbidden (insufficient permissions) 404 - Not found 409 - Conflict (e.g., duplicate resource) 422 - Unprocessable entity (validation) 500 - Internal server error Error Response Formats Simple Error example.tsTypeScript ```typescript res.error('Not found', 404); ``` JSON ```json { "error": "Not found" } ``` Detailed Error example.tsTypeScript ```typescript res.json({ error: 'Validation failed', details: [ { field: 'email', message: 'Invalid email format' }, { field: 'password', message: 'Too short' }, ], }, 422); ``` JSON ```json { "error": "Validation failed", "details": [ { "field": "email", "message": "Invalid email format" }, { "field": "password", "message": "Too short" } ] } ``` Best Practices Fail fast - Validate early with middleware and guards Use custom error classes - Type-safe error handling Be consistent - Use the same error format across your API Don't leak sensitive info - Hide stack traces in production Log errors - Always log unexpected errors for debugging Use appropriate status codes - Help clients understand errors utils/error-response.tsTypeScript ```typescript interface ErrorResponse { error: string; details?: unknown; code?: string; } const sendError = (res: any, message: string, status: number, details?: unknown) => { const response: ErrorResponse = { error: message }; if (details) { response.details = details; } res.json(response, status); }; ``` Next Steps Validation Testing Middleware --- # OpenAPI Generation URL: https://justscale.sh/docs/techniques/openapi OpenAPI Generation OpenAPI 3.1 generated from the DI graph @justscale/feature-openapiemits OpenAPI 3.1 from the scope's AbstractContainer. Security schemes, permission-scoped responses, and model schemas are all derived from the composed graph — not a parallel annotation stream like swagger-jsdoc. There is nothing to decorate and no document to hand-assemble; if a controller exists in a scope, its routes show up in that scope's spec. Installation Bash ```bash pnpm add @justscale/feature-openapi ``` Wiring Provide an OpenApiConfig partial and add the feature. Everything else is defaults. src/app.tsTypeScript ```typescript import JustScale, { createConfig } from '@justscale/core'; import { OpenApiConfig, OpenApiFeature } from '@justscale/feature-openapi'; import { PlayersController } from './controllers/players'; const openApiConfig = createConfig({ provides: [OpenApiConfig], factory: () => ({ [OpenApiConfig.key]: { info: { title: 'Poker API', version: '1.0.0' }, }, }), }); const app = JustScale() .add(openApiConfig) .add(OpenApiFeature) .add(PlayersController) .build(); await app.serve({ http: 3000 }); ``` Defaults: specPath: '/openapi.json', docsPath: '/docs', ui: 'scalar', cache: true. Override any of them on the config partial — scopes don't share values. Per-Scope Specs Each compiled JustScale() scope binds its own AbstractContainer, so reflection is scope-local. Add the feature inside a sub-app and you get a spec covering only that sub-app's controllers — no manual path filtering, no shared state with the root. src/admin/admin.sub-app.tsTypeScript ```typescript import JustScale, { createConfig } from '@justscale/core'; import { OpenApiConfig, OpenApiFeature } from '@justscale/feature-openapi'; import { CatalogService } from '../domains/catalog/catalog.service.js'; import { InventoryService } from '../domains/catalog/inventory.service.js'; import { AdminStockController } from './admin-stock.controller.js'; // Admin-scoped OpenAPI config: different title + paths from the shop // root, pinned to the admin surface. Because each JustScale() scope // binds its own AbstractContainer, the spec emitted here reflects // only the controllers in this sub-app — not the shop's. const AdminOpenApiConfig = createConfig({ provides: [OpenApiConfig], factory: () => ({ [OpenApiConfig.key]: { info: { title: 'Admin API', version: '1.0.0' }, specPath: '/admin/openapi.json', docsPath: '/admin/docs', }, }), }); export const AdminSubApp = JustScale() .requires(CatalogService) .requires(InventoryService) .add(AdminStockController) .add(AdminOpenApiConfig) .add(OpenApiFeature) .build(); ``` Mounted under the shop root, the admin surface serves /admin/openapi.json and /admin/docsfrom its own container. The shop's /openapi.json never sees the admin routes; the admin spec never sees shop routes. UI Flavours ui: 'scalar' — the default. Modern single-page renderer, good for public-facing docs. ui: 'swagger' — Swagger UI, familiar to teams used to the traditional explorer. ui: 'none' — disables the HTML route. The JSON spec at specPath stays reachable; pick this when the spec is only consumed by tooling. What Gets Auto-Derived Security schemes. Auth middlewares publish an AUTH_SCHEME symbol; the generator lifts it into components.securitySchemes and attaches security to each route that used the middleware. No duplicate config. Permission-scoped responses. Routes declared with .returns(status, schema, permission) fold into a oneOf under that status; each alternative carries an x-permission annotation. A .guard(Model.can.X) also emits x-permissions on the operation and a default 403. Model schemas. Models declared with defineModel are converted through modelToJsonSchema / jsonSchemaFor and emitted as $ref-backed components the first time they're encountered. Zod body + query. .body(schema) and .query(schema) publish symbols read by the generator and feed zodToJsonSchema. Path parameters come from the route pattern. Emitting the Spec Outside HTTP buildOpenApiDocument(container, opts) is exported for callers that want the raw document — writing it to disk at build time, feeding a contract-testing pipeline, whatever. Pass the AbstractContainer of the scope you want to reflect on. scripts/emit-spec.tsTypeScript ```typescript import { AbstractContainer } from '@justscale/core'; import { buildOpenApiDocument } from '@justscale/feature-openapi'; import { writeFile } from 'node:fs/promises'; import { app } from '../src/app.js'; const container = app.container.resolve(AbstractContainer); const doc = buildOpenApiDocument(container, { info: { title: 'Poker API', version: '1.0.0' }, }); await writeFile('openapi.json', JSON.stringify(doc, null, 2)); ``` Caching cache: trueis the default — the spec is built on first request and memoised per scope. Dev hot-reload invalidates it naturally because the container itself is rebuilt, so there's no bust step to remember. Set cache: falseonly if you're mutating reflection at runtime and want each request to regenerate. Next Steps Permissions Authentication Features --- # Testing URL: https://justscale.sh/docs/techniques/testing Testing Test your application with @justscale/testing The @justscale/testing package provides utilities for testing JustScale applications with full type safety. Test your controllers, services, and middleware using Node.js test runner or your preferred test framework. Installation Bash ```bash pnpm add --save-dev @justscale/testing ``` Test Client Create a test client to interact with your application: test/api.test.tsTypeScript ```typescript import { describe, it, after } from 'node:test'; import assert from 'node:assert'; import { createTestClient } from '@justscale/testing'; import { httpTransport } from '@justscale/http/testing'; import { app } from '../src/app'; describe('API Tests', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, transportOptions: { http: { port: 0 } } }); after(() => client.close()); it('should create a player', async () => { const { status, data } = await client.http.post('/players', { name: 'Alice', chips: 1000, }); assert.strictEqual(status, 200); assert.strictEqual(data.player.name, 'Alice'); }); }); ``` Typed Controller Access Get type-safe access to your controllers for better DX: test/typed-api.test.tsTypeScript ```typescript import { describe, it } from 'node:test'; import assert from 'node:assert'; import { createTestClient } from '@justscale/testing'; import { httpTransport } from '@justscale/http/testing'; import { app } from '../src/app'; import { PlayersController } from '../src/controllers/players'; describe('Typed API', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, transportOptions: { http: { port: 0 } } }); const api = client.http.controllers({ player: PlayersController, }); it('supports typed requests', async () => { // GET /players/:playerId await api.player.getOne({ playerId: '123' }); // POST /players with body await api.player.create({ name: 'Bob', chips: 500 }); // PATCH /players/:playerId with body const { data } = await api.player.update({ playerId: '123', name: 'Updated Name', }); assert.strictEqual(data.player.name, 'Updated Name'); }); }); ``` Service Access Access services directly for unit testing: test/services.test.tsTypeScript ```typescript import { describe, it } from 'node:test'; import assert from 'node:assert'; import { createTestClient } from '@justscale/testing'; import { httpTransport } from '@justscale/http/testing'; import { app } from '../src/app'; import { PlayerRepository } from '../src/services/player-repository'; describe('Service Tests', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, }); const { players } = client.services({ players: PlayerRepository, }); it('should test services directly', async () => { const allPlayers = await players.find(); assert.ok(Array.isArray(allPlayers)); const player = await players.save({ name: 'Alice', chips: 1000 }); assert.strictEqual(player.name, 'Alice'); }); }); ``` Mocking and Spies Spy on Service Methods Use spyOn() to track calls to service methods: test/spy.test.tsTypeScript ```typescript import { describe, it } from 'node:test'; import { createTestClient, spyOn, assertCallCount } from '@justscale/testing'; import { httpTransport } from '@justscale/http/testing'; import { app } from '../src/app'; import { PlayerRepository } from '../src/services/player-repository'; describe('Spy Tests', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, }); it('should spy on service methods', async () => { const { players } = client.services({ players: PlayerRepository }); // Use 'using' for automatic cleanup using spy = spyOn(players); await players.find(); assertCallCount(spy.spied.find, 1); // Original methods automatically restored when block exits }); }); ``` Mock Functions Create mock functions with mockFn(): test/mock-fn.test.tsTypeScript ```typescript import { describe, it } from 'node:test'; import assert from 'node:assert'; import { mockFn, assertCallCount } from '@justscale/testing'; describe('Mock Function Tests', () => { it('should create mock functions', async () => { const mockFind = mockFn() .returns(Promise.resolve([ { name: 'Alice', chips: 1000 } ])); // Use in service mock const mockService = { find: mockFind, }; const result = await mockService.find(); assert.strictEqual(result[0].name, 'Alice'); assertCallCount(mockFind, 1); }); }); ``` Mock Service Methods test/mock-service.test.tsTypeScript ```typescript import { describe, it } from 'node:test'; import assert from 'node:assert'; import { mockService, mockFn } from '@justscale/testing'; import { PlayerRepository } from '../src/services/player-repository'; describe('Mock Service Tests', () => { it('should mock service methods', async () => { const mockedPlayers = mockService(PlayerRepository, { get: mockFn().returns(Promise.resolve({ name: 'Mocked Player', chips: 500, })), save: mockFn().returns(Promise.resolve({ name: 'Saved Player', chips: 1000, })), }); const player = await mockedPlayers.get(Player.ref`1`); assert.strictEqual(player.name, 'Mocked Player'); }); }); ``` Testing with createTestApp For lower-level testing without HTTP, use createTestApp: test/create-test-app.test.tsTypeScript ```typescript import { createTestApp, mockFn } from '@justscale/testing'; import { UserRepository } from '../src/repositories/user-repository'; import { UserService } from '../src/services/user-service'; import { UsersController } from '../src/controllers/users'; const app = createTestApp({ services: [UserRepository, UserService], controllers: [UsersController], }); // Mock a dependency app.mock(UserRepository, { get: mockFn().returns(Promise.resolve({ name: 'Test User' })), }); // Access services const userService = app.service(UserService); // Use the app for testing const matched = app.match('GET', '/users/1'); ``` Testing Validation Test that validation errors are handled correctly: test/validation.test.tsTypeScript ```typescript import { describe, it } from 'node:test'; import assert from 'node:assert'; import { createTestClient } from '@justscale/testing'; import { httpTransport } from '@justscale/http/testing'; import { app } from '../src/app'; import { PlayersController } from '../src/controllers/players'; describe('Validation Tests', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, }); const api = client.http.controllers({ player: PlayersController, }); it('should reject invalid input', async () => { const { status, data } = await api.player.create({ name: '', // Invalid: empty name chips: -100, // Invalid: negative chips } as any); assert.strictEqual(status, 400); assert.ok(data.error); }); it('should accept valid input', async () => { const { status, data } = await api.player.create({ name: 'Valid Player', chips: 1000, }); assert.strictEqual(status, 200); assert.strictEqual(data.player.name, 'Valid Player'); }); }); ``` Testing Authentication Use createUserSession helper for auth testing: test/auth.test.tsTypeScript ```typescript import { describe, it } from 'node:test'; import assert from 'node:assert'; import { createTestClient } from '@justscale/testing'; import { createUserSession } from '@justscale/http/testing'; import { httpTransport } from '@justscale/http/testing'; import { app } from '../src/app'; import { AuthController } from '../src/controllers/auth'; import { ProtectedController } from '../src/controllers/protected'; describe('Auth Tests', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, }); const api = client.http.controllers({ auth: AuthController, protected: ProtectedController, }); it('should handle user sessions', async () => { const user = createUserSession(api, { captureToken: (route, res) => { if (route === 'auth.register') { return res.data?.token; } }, }); // Register - token is auto-captured await user.api.auth.register({ email: 'test@example.com', password: 'password123', }); // Already authenticated const { status, data } = await user.api.protected.profile(); assert.strictEqual(status, 200); assert.strictEqual(data.email, 'test@example.com'); }); }); ``` Multi-User Testing Test interactions between multiple users: test/multi-user.test.tsTypeScript ```typescript import { describe, it } from 'node:test'; import assert from 'node:assert'; import { createTestClient } from '@justscale/testing'; import { createUserSession } from '@justscale/http/testing'; import { httpTransport } from '@justscale/http/testing'; import { app } from '../src/app'; import { AuthController } from '../src/controllers/auth'; import { ProtectedController } from '../src/controllers/protected'; describe('Multi-User Tests', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, }); const api = client.http.controllers({ auth: AuthController, protected: ProtectedController, }); it('should handle multiple users independently', async () => { const alice = createUserSession(api); const bob = createUserSession(api); // Alice registers const aliceRes = await alice.api.auth.register({ email: 'alice@example.com', password: 'password123', }); alice.setToken(aliceRes.data.token); // Bob registers const bobRes = await bob.api.auth.register({ email: 'bob@example.com', password: 'password123', }); bob.setToken(bobRes.data.token); // Each sees their own profile const aliceProfile = await alice.api.protected.profile(); const bobProfile = await bob.api.protected.profile(); assert.strictEqual(aliceProfile.data.email, 'alice@example.com'); assert.strictEqual(bobProfile.data.email, 'bob@example.com'); }); }); ``` Assertion Helpers Use built-in assertion helpers for cleaner tests: test/assertions.test.tsTypeScript ```typescript import { describe, it } from 'node:test'; import { mockFn, assertCalledWith, assertCallCount, assertNotCalled, } from '@justscale/testing'; describe('Assertion Helpers', () => { it('should verify mock function calls', async () => { const mockGet = mockFn().returns(Promise.resolve(null)); await mockGet(Player.ref`123`); await mockGet(Player.ref`456`); // Verify call count assertCallCount(mockGet, 2); // Verify called with specific args assertCalledWith(mockGet, [Player.ref`123`]); // Verify not called const mockDelete = mockFn(); assertNotCalled(mockDelete); }); }); ``` Cleanup Always clean up test clients to avoid port conflicts: test/cleanup.test.tsTypeScript ```typescript import { describe, it, after } from 'node:test'; import { createTestClient } from '@justscale/testing'; import { httpTransport } from '@justscale/http/testing'; import { app } from '../src/app'; describe('Tests', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, }); // Clean up after all tests after(() => client.close()); it('test case 1', async () => { // Test code }); }); ``` Use using keyword for automatic cleanup: test/using-cleanup.test.tsTypeScript ```typescript import { spyOn } from '@justscale/testing'; it('should spy on methods', async () => { using spy = spyOn(service); // Test code // Spy automatically cleaned up when block exits }); ``` Best Practices Use typed controllers - Better DX with autocomplete Test at multiple levels - Unit tests for services, integration tests for controllers Mock external dependencies - Keep tests fast and isolated Clean up resources - Always close test clients Use assertion helpers - More readable test code Test error cases - Verify validation and error handling Example Test Suite test/players.test.tsTypeScript ```typescript import { describe, it, after } from 'node:test'; import assert from 'node:assert'; import { createTestClient, spyOn, assertCallCount } from '@justscale/testing'; import { httpTransport } from '@justscale/http/testing'; import { app } from '../src/app'; import { PlayersController } from '../src/controllers/players'; import { PlayerRepository } from '../src/services/player-repository'; describe('Players API', async () => { const client = await createTestClient(app, { transports: { http: httpTransport }, transportOptions: { http: { port: 0 } } }); const api = client.http.controllers({ player: PlayersController, }); after(() => client.close()); it('should list all players', async () => { const { status, data } = await api.player.list(); assert.strictEqual(status, 200); assert.ok(Array.isArray(data.players)); }); it('should create a player', async () => { const { status, data } = await api.player.create({ name: 'Alice', chips: 2000, }); assert.strictEqual(status, 200); assert.strictEqual(data.player.name, 'Alice'); assert.strictEqual(data.player.chips, 2000); }); it('should get a player', async () => { const { status, data } = await api.player.getOne({ playerId: '123' }); assert.strictEqual(status, 200); assert.strictEqual(data.player.name, 'Bob'); }); it('should spy on repository calls', async () => { const { players } = client.services({ players: PlayerRepository }); using spy = spyOn(players); await players.find(); assertCallCount(spy.spied.find, 1); }); }); ``` Next Steps Validation Error Handling Controllers --- # Validation URL: https://justscale.sh/docs/techniques/validation Validation Validate input with Zod schemas JustScale uses Zod for runtime validation and type inference. Zod schemas validate request bodies, query parameters, and responses while providing full TypeScript safety. Request Body Validation Use the body() plugin to validate and parse request bodies: src/controllers/players.tsTypeScript ```typescript import { z } from 'zod'; import { createController } from '@justscale/core'; import { Post, body } from '@justscale/http/builder'; import { PlayerRepository } from '../repositories/player-repository'; const CreatePlayerSchema = z.object({ name: z.string().min(1, 'Name is required'), chips: z.number().int().positive().default(1000), email: z.string().email().optional(), }); export const PlayersController = createController('/players', { inject: { players: PlayerRepository }, routes: (services) => ({ create: Post('/') .body(CreatePlayerSchema) .handle(async ({ body, res }) => { // body is typed as { name: string; chips: number; email?: string } const player = await services.players.save(body); res.json({ player }); }), }), }); ``` What Happens Request body is parsed from JSON Zod validates against the schema If validation fails, automatic 400 error response If successful, typed body is added to context TypeScript knows the exact shape of body Query Parameter Validation Use the query() plugin for validating URL query parameters: src/controllers/players.tsTypeScript ```typescript import { z } from 'zod'; import { createController } from '@justscale/core'; import { Get, query } from '@justscale/http/builder'; import { PlayerRepository } from '../repositories/player-repository'; const PaginationSchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(10), search: z.string().optional(), }); export const PlayersController = createController('/players', { inject: { players: PlayerRepository }, routes: (services) => ({ list: Get('/') .query(PaginationSchema) .handle(async ({ query, res }) => { // query is typed as { page: number; limit: number; search?: string } const offset = (query.page - 1) * query.limit; const results = await services.players.find({ limit: query.limit, offset, where: query.search ? { name: query.search } : undefined, }); res.json({ players: results, page: query.page }); }), }), }); ``` Note: Use z.coerce.number() to automatically convert string query params to numbers. Type Inference Zod schemas provide automatic type inference: TypeScript ```typescript import { import zz } from 'zod'; const const PlayerSchema: z.ZodObject<{ id: z.ZodString; name: z.ZodString; chips: z.ZodNumber; }, z.core.$strip> ``` PlayerSchema = import zz. ``` function object<{ id: z.ZodString; name: z.ZodString; chips: z.ZodNumber; }>(shape?: { id: z.ZodString; name: z.ZodString; chips: z.ZodNumber; }, params?: string | { error?: string | z.core.$ZodErrorMap | z.core.$ZodIssueUnrecognizedKeys>>; message?: string | undefined; }): z.ZodObject<{ id: z.ZodString; name: z.ZodString; chips: z.ZodNumber; }, z.core.$strip> ``` object({ id: z.ZodStringid: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string(), name: z.ZodStringname: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string(), chips: z.ZodNumberchips: import zz.function number(params?: string | z.core.$ZodNumberParams): z.ZodNumbernumber(), }); // Extract the TypeScript type type ``` type Player = { id: string; name: string; chips: number; } ``` Player = import zz. ``` type infer = T extends { _zod: { output: any; }; } ? T["_zod"]["output"] : unknown export infer ``` infer ``` PlayerSchema>; You can use these inferred types throughout your application: src/services/player-service.tsTypeScript ```typescript import { z } from 'zod'; import { defineService } from '@justscale/core'; import type { Player } from '../schemas/player'; const CreatePlayerSchema = z.object({ name: z.string(), chips: z.number().default(1000), }); type CreatePlayerInput = z.infer; // Use in service methods export class PlayerService extends defineService({ inject: {}, factory: () => ({ async createPlayer(input: CreatePlayerInput): Promise { return { id: crypto.randomUUID(), ...input }; }, }), }) {} ``` Advanced Validation Patterns Custom Error Messages src/schemas/user.tsTypeScript ```typescript import { z } from 'zod'; const UserSchema = z.object({ email: z.string().email('Invalid email format'), age: z.number().min(18, 'Must be at least 18 years old'), password: z.string() .min(8, 'Password must be at least 8 characters') .regex(/[A-Z]/, 'Password must contain an uppercase letter'), }); type User = z.infer; ``` Conditional Validation src/schemas/order.tsTypeScript ```typescript import { z } from 'zod'; const OrderSchema = z.object({ type: z.enum(['pickup', 'delivery']), address: z.string().optional(), }).refine( (data) => data.type !== 'delivery' || data.address, { message: 'Address is required for delivery orders', path: ['address'], } ); type Order = z.infer; ``` Nested Objects src/schemas/game.tsTypeScript ```typescript import { z } from 'zod'; const CreateGameSchema = z.object({ name: z.string(), settings: z.object({ maxPlayers: z.number().min(2).max(10), buyIn: z.number().positive(), blinds: z.object({ small: z.number().positive(), big: z.number().positive(), }), }), }); type CreateGameInput = z.infer; ``` Arrays src/controllers/players.tsTypeScript ```typescript import { z } from 'zod'; import { createController } from '@justscale/core'; import { Post, body } from '@justscale/http/builder'; import { PlayerRepository } from '../repositories/player-repository'; const BulkCreateSchema = z.object({ players: z.array( z.object({ name: z.string(), chips: z.number().default(1000), }) ).min(1, 'At least one player required'), }); export const PlayersController = createController('/players', { inject: { players: PlayerRepository }, routes: (services) => ({ bulkCreate: Post('/bulk') .body(BulkCreateSchema) .handle(async ({ body, res }) => { // body.players is an array const created = await services.players.saveMany(body.players); res.json({ players: created }); }), }), }); ``` Error Handling When validation fails, JustScale automatically returns a 400 error with validation details: JSON ```json { "error": "Validation failed", "details": [ { "path": ["name"], "message": "Name is required" }, { "path": ["chips"], "message": "Expected number, received string" } ] } ``` To customize error handling, create a custom validation middleware: src/middleware/custom-parse-body.tsTypeScript ```typescript import { z } from 'zod'; export const customParseBody = (schema: z.ZodType) => async (ctx: any) => { const result = schema.safeParse(ctx.body); if (!result.success) { // Custom error format ctx.res.error('Invalid request data', 422); throw new Error('Validation failed'); } return { body: result.data }; }; ``` Best Practices Define schemas near usage - Keep schemas close to controllers Reuse schemas - Share validation logic between routes Use defaults - Provide sensible defaults for optional fields Coerce types - Use z.coerce for query parameters Validate early - Fail fast with middleware validation Document schemas - Use .describe() for OpenAPI generation src/schemas/player.tsTypeScript ```typescript import { z } from 'zod'; const PlayerSchema = z.object({ name: z.string().describe('Player display name'), chips: z.number().describe('Starting chip count').default(1000), }); ``` Next Steps OpenAPI Error Handling Testing --- # Contextual Controllers URL: https://justscale.sh/docs/websocket/contextual Contextual Controllers Build RPC-style WebSocket APIs with procedures Contextual controllers let you define WebSocket APIs as a collection of procedures, similar to tRPC or JSON-RPC. Instead of handling raw messages, you define typed procedures that are invoked by command name. Why Contextual Controllers? Traditional WebSocket handlers process messages in a single loop: traditional.tsTypeScript ```typescript // Traditional approach - one big switch Ws('/').handle(async ({ messages, send }) => { for await (const msg of messages) { switch (msg.type) { case 'join': // join logic... break; case 'leave': // leave logic... break; case 'message': // message logic... break; // Gets unwieldy with many message types } } }); ``` Contextual controllers split this into focused procedures: contextual.tsTypeScript ```typescript // Contextual approach - separate procedures const ChatProcedures = createController .withContext() .create({ routes: (_, { Procedure }) => ({ join: Procedure('room/:roomId/join') .handle(({ session, params }) => { // Just join logic }), leave: Procedure('room/:roomId/leave') .handle(({ session, params }) => { // Just leave logic }), message: Procedure('room/:roomId/message') .body(z.object({ content: z.string() })) .handle(({ session, params, body }) => { // Just message logic }), }), }); ``` Creating a Contextual Controller Use createController.withContext().create(...) to build a controller bound to a session type. The context type represents the connection state: Files srcchat-procedures.tschat-service.tschat-session.ts srcchat-procedures.tschat-service.tschat-session.ts src/chat-procedures.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { z } from 'zod'; import { ChatService } from './chat-service'; import type { ChatSession } from './chat-session'; export const ChatProcedures = createController .withContext() .create({ inject: { chat: ChatService }, routes: (services, { Procedure }) => ({ // Procedures have access to session context — typed as ChatSession. join: Procedure('room/:roomId/join') .handle(async function* ({ session, params }) { const { roomId } = params; if (session.rooms.has(roomId)) { return { error: 'already_joined' }; } const subscription = services.chat.subscribe(roomId); session.rooms.set(roomId, { subscription }); // Generators can stream responses. for await (const msg of subscription) { yield msg; } }), leave: Procedure('room/:roomId/leave') .handle(({ session, params }) => { const room = session.rooms.get(params.roomId); if (room) { room.subscription.unsubscribe(); session.rooms.delete(params.roomId); } return { left: params.roomId }; }), message: Procedure('room/:roomId/message') .body(z.object({ content: z.string() })) .handle(({ session, params, body }) => { services.chat.broadcast(params.roomId, { type: 'message', username: session.username, content: body.content, timestamp: Date.now(), }); }), }), }); ``` Using Procedures in WebSocket Create a session and run it in your WebSocket handler: chat-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Ws } from '@justscale/websocket'; import { z } from 'zod'; import { ChatProcedures, type ChatSession } from './chat-procedures'; // Command message format const CommandSchema = z.object({ command: z.string(), // e.g., "room/general/join" payload: z.record(z.unknown()).optional(), }); export const ChatController = createController('/chat', { inject: { procedures: ChatProcedures }, routes: (services) => ({ ws: Ws('/') .message(CommandSchema) .handle(async ({ messages, send, query }) => { const username = query.username || 'Anonymous'; // Build session context const session: ChatSession = { username, ws: { rawMessages: async function* () { for await (const msg of messages) { yield JSON.stringify(msg); } }, send: (data: string) => send(JSON.parse(data)), }, send: (msg) => send(msg), rooms: new Map(), }; // Create procedural session using procedureSession = services.procedures.createSession(session); // Clean up subscriptions on disconnect procedureSession.onDispose(() => { for (const [, room] of session.rooms) { room.subscription.unsubscribe(); } }); // Run the session - routes commands to procedures await procedureSession.run(); }), }), }); ``` ℹ️Info The session.run() method reads commands from the WebSocket, matches them to procedures, and invokes handlers. Generator responses are streamed back to the client. Procedure Patterns Path Parameters Procedures support path parameters just like HTTP routes: path-params.tsTypeScript ```typescript // Client sends: { command: "room/general/join" } join: Procedure('room/:roomId/join') .handle(({ params }) => { // params.roomId === 'general' }), // Client sends: { command: "user/123/follow" } follow: Procedure('user/:userId/follow') .handle(({ params }) => { // params.userId === '123' }), ``` Request Body Use .body() to validate the command payload: body-validation.tsTypeScript ```typescript // Client sends: { command: "room/general/message", payload: { content: "Hi!" } } message: Procedure('room/:roomId/message') .body(z.object({ content: z.string().min(1).max(1000), })) .handle(({ params, body }) => { // body.content is validated and typed console.log(`Message in ${params.roomId}: ${body.content}`); }), ``` Middleware Apply middleware to individual procedures: middleware.tsTypeScript ```typescript // Middleware: require room membership function requireMembership({ session, params }: { session: ChatSession; params: { roomId: string }; }) { if (!session.rooms.has(params.roomId)) { throw new Error(`Not a member of ${params.roomId}`); } return { room: session.rooms.get(params.roomId)! }; } // Apply to procedure message: Procedure('room/:roomId/message') .use(requireMembership) // Adds 'room' to context .body(z.object({ content: z.string() })) .handle(({ room, body }) => { // Can access room from middleware }), ``` Streaming Responses Use async generators to stream multiple responses: streaming.tsTypeScript ```typescript // Generator procedure - streams responses join: Procedure('room/:roomId/join') .handle(async function*({ session, params }) { const subscription = services.chat.subscribe(params.roomId); // Send immediate response yield { type: 'joined', roomId: params.roomId }; // Stream messages as they arrive for await (const msg of subscription) { yield msg; // Each yield sends to client } // When subscription ends, generator completes }), // Non-generator - single response leave: Procedure('room/:roomId/leave') .handle(({ params }) => { // Single return value sent to client return { left: params.roomId }; }), ``` Session Lifecycle The session provides lifecycle hooks for cleanup: lifecycle.tsTypeScript ```typescript // Create session with using for auto-disposal using session = services.procedures.createSession(context); // Register cleanup handler session.onDispose(() => { // Called when: // - Client disconnects // - Handler returns/throws // - using block exits // Clean up subscriptions for (const [, room] of context.rooms) { room.subscription.unsubscribe(); } // Broadcast leave to all rooms for (const [roomId] of context.rooms) { services.chat.broadcast(roomId, { type: 'leave', username: context.username, timestamp: Date.now(), }); } }); // Run the session await session.run(); ``` Error Handling Errors in procedures are caught and sent to the client: errors.tsTypeScript ```typescript message: Procedure('room/:roomId/message') .handle(({ params, body }) => { // Throwing sends error to client if (!body.content.trim()) { throw new Error('Message cannot be empty'); } // Validation errors from .body() are also sent }), // Client receives: // { error: 'Message cannot be empty', command: 'room/general/message' } ``` Client Protocol The client sends commands as JSON objects: client-example.tsTypeScript ```javascript const ws = new WebSocket('ws://localhost:3000/chat?username=alice'); // Join a room ws.send(JSON.stringify({ command: 'room/general/join', })); // Send a message ws.send(JSON.stringify({ command: 'room/general/message', payload: { content: 'Hello everyone!' }, })); // Leave the room ws.send(JSON.stringify({ command: 'room/general/leave', })); // Handle responses ws.onmessage = (event) => { const msg = JSON.parse(event.data); console.log('Received:', msg); }; ``` Complete Example A full chat application with contextual controllers: chat-procedures.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { z } from 'zod'; import { ChatService } from './chat-service'; interface ChatSession { username: string; send: (msg: unknown) => void; rooms: Map; } export const ChatProcedures = createController .withContext() .create({ inject: { chat: ChatService }, routes: (services, { Procedure }) => ({ join: Procedure('room/:roomId/join') .handle(async function*({ session, params }) { const { roomId } = params; if (session.rooms.has(roomId)) return { error: 'already_joined' }; const sub = services.chat.subscribe(roomId); session.rooms.set(roomId, { subscription: sub }); services.chat.broadcast(roomId, { type: 'join', username: session.username, timestamp: Date.now(), }); yield { type: 'users', users: services.chat.getMembers(roomId) }; for await (const msg of sub) yield msg; }), leave: Procedure('room/:roomId/leave') .handle(({ session, params }) => { const room = session.rooms.get(params.roomId); if (!room) return { error: 'not_member' }; room.subscription.unsubscribe(); session.rooms.delete(params.roomId); services.chat.broadcast(params.roomId, { type: 'leave', username: session.username, timestamp: Date.now(), }); return { left: params.roomId }; }), message: Procedure('room/:roomId/message') .body(z.object({ content: z.string() })) .handle(({ session, params, body }) => { if (!session.rooms.has(params.roomId)) { throw new Error('Not a member'); } services.chat.broadcast(params.roomId, { type: 'message', username: session.username, content: body.content, timestamp: Date.now(), }); }), }), }); ``` Next Steps Rooms & Broadcasting Channels Testing --- # Message Handling URL: https://justscale.sh/docs/websocket/messages Message Handling Validate, process, and respond to WebSocket messages WebSocket handlers receive messages through an async iterable, making it natural to process streams of data. This guide covers message validation, processing patterns, and error handling. The Message Loop Every WebSocket handler receives a messages async iterable. Use for await to process messages as they arrive: message-loop.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Ws } from '@justscale/websocket'; export const EchoController = createController('/echo', { routes: () => ({ ws: Ws('/').handle(async ({ messages, send }) => { // Loop runs until client disconnects for await (const message of messages) { console.log('Received:', message); send({ echo: message }); } // Loop exits when connection closes console.log('Client disconnected'); }), }), }); ``` ℹ️Info The messages iterable yields parsed JSON objects. Raw string messages are automatically parsed before reaching your handler. Message Validation Use the .message() builder to validate incoming messages with a Zod schema. Invalid messages are silently dropped: validated-messages.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Ws } from '@justscale/websocket'; import { z } from 'zod'; // Define message schema const ChatMessage = z.object({ type: z.enum(['message', 'typing', 'ping']), content: z.string().optional(), roomId: z.string(), }); export const ChatController = createController('/chat', { routes: () => ({ ws: Ws('/') .message(ChatMessage) // Validates all messages .handle(async ({ messages, send }) => { for await (const msg of messages) { // msg is typed: { type: 'message' | 'typing' | 'ping', ... } switch (msg.type) { case 'message': send({ received: msg.content }); break; case 'typing': // Broadcast typing indicator break; case 'ping': send({ type: 'pong' }); break; } } }), }), }); ``` Custom Validation Errors By default, invalid messages are dropped. To send validation errors to the client, handle validation manually: validation-errors.tsTypeScript ```typescript import { Ws } from '@justscale/websocket'; import { z } from 'zod'; const MessageSchema = z.object({ type: z.string(), payload: z.unknown(), }); Ws('/').handle(async ({ messages, send }) => { for await (const raw of messages) { const result = MessageSchema.safeParse(raw); if (!result.success) { send({ error: 'validation_failed', details: result.error.flatten(), }); continue; } // Process validated message handleMessage(result.data); } }); ``` Discriminated Message Types For protocols with multiple message types, use Zod's discriminated union to get type-safe handling: discriminated-messages.tsTypeScript ```typescript import { z } from 'zod'; import { Ws } from '@justscale/websocket'; // Define each message type const JoinMessage = z.object({ type: z.literal('join'), roomId: z.string(), username: z.string(), }); const LeaveMessage = z.object({ type: z.literal('leave'), roomId: z.string(), }); const ChatMessage = z.object({ type: z.literal('chat'), roomId: z.string(), content: z.string(), }); // Combine with discriminated union const ClientMessage = z.discriminatedUnion('type', [ JoinMessage, LeaveMessage, ChatMessage, ]); Ws('/') .message(ClientMessage) .handle(async ({ messages, send }) => { for await (const msg of messages) { // TypeScript knows the exact type based on msg.type switch (msg.type) { case 'join': // msg is { type: 'join', roomId: string, username: string } console.log(`${msg.username} joining ${msg.roomId}`); break; case 'leave': // msg is { type: 'leave', roomId: string } console.log(`User left ${msg.roomId}`); break; case 'chat': // msg is { type: 'chat', roomId: string, content: string } send({ echo: msg.content }); break; } } }); ``` Sending Messages The send() function sends data to the connected client. Objects are automatically serialized to JSON: sending-messages.tsTypeScript ```typescript Ws('/').handle(async ({ messages, send }) => { // Send object (serialized to JSON) send({ type: 'welcome', timestamp: Date.now() }); // Send array send([1, 2, 3]); // Send string (sent as-is) send('Hello'); for await (const msg of messages) { // Reply to each message send({ received: true, id: msg.id }); } }); ``` Error Handling Errors inside the message loop close the connection. Wrap individual message processing in try-catch to keep the connection alive: error-handling.tsTypeScript ```typescript import { Ws } from '@justscale/websocket'; Ws('/').handle(async ({ messages, send, logger }) => { try { for await (const msg of messages) { try { // Process message - errors here don't close connection const result = await processMessage(msg); send({ success: true, result }); } catch (error) { // Send error to client send({ success: false, error: error instanceof Error ? error.message : 'Unknown error', }); logger.warn('Message processing failed', { error }); } } } catch (error) { // Connection-level error (network issues, etc.) logger.error('WebSocket connection error', { error }); } finally { // Always runs on disconnect logger.info('Client disconnected'); } }); ``` Connection Lifecycle The handler context provides lifecycle controls: closed - Promise that resolves when connection closes close(code?, reason?) - Gracefully close the connection lifecycle.tsTypeScript ```typescript Ws('/').handle(async ({ messages, send, close, closed }) => { // Set up timeout - close if no messages for 30 seconds let timeout: NodeJS.Timeout; const resetTimeout = () => { clearTimeout(timeout); timeout = setTimeout(() => { close(4000, 'Idle timeout'); }, 30000); }; resetTimeout(); // Listen for close event closed.then(() => { clearTimeout(timeout); console.log('Connection closed'); }); for await (const msg of messages) { resetTimeout(); // Reset on each message if (msg.type === 'quit') { close(1000, 'Client requested close'); break; } send({ received: msg }); } }); ``` Query Parameters Access URL query parameters via the query object: query-params.tsTypeScript ```typescript // Client connects to: ws://localhost:6142/chat?token=abc&room=general Ws('/chat').handle(async ({ messages, send, query }) => { // Access query parameters const token = query.token; // 'abc' const room = query.room; // 'general' // Validate token if (!isValidToken(token)) { send({ error: 'Invalid token' }); return; // Closes connection } send({ joined: room }); for await (const msg of messages) { // ... } }); ``` Binary Messages For binary data, messages arrive as ArrayBuffer. Check the type before processing: binary-messages.tsTypeScript ```typescript Ws('/binary').handle(async ({ messages, send }) => { for await (const msg of messages) { if (msg instanceof ArrayBuffer) { // Handle binary data const bytes = new Uint8Array(msg); console.log('Received', bytes.length, 'bytes'); // Echo back send(msg); } else { // Handle JSON message send({ received: msg }); } } }); ``` Next Steps Rooms & Broadcasting Contextual Controllers Channels --- # WebSocket URL: https://justscale.sh/docs/websocket/overview WebSocket Real-time bidirectional communication with WebSocket routes The @justscale/websocket package provides WebSocket route support using the same controller pattern as HTTP. Messages are handled via async iterables, making it natural to work with streams of data. Installation Bash ```bash pnpm add @justscale/core @justscale/core/cluster @justscale/websocket zod ``` Complete Example A real-time chat application with rooms, message broadcasting, and user tracking: Files srccontrollerschat.ts modelsroom.ts schemasmessage.ts servicesroom-service.ts index.ts srccontrollerschat.ts modelsroom.ts schemasmessage.ts servicesroom-service.ts index.ts src/controllers/chat.tsTypeScript ```typescript import { createController } from '@justscale/core' import { Ws } from '@justscale/websocket' import { ClientMessage } from '../schemas/message' import type { ChatMessage } from '../models/room' import { RoomService } from '../services/room-service' export const ChatController = createController('/chat', { inject: { rooms: RoomService }, routes: ({ rooms }) => ({ // WebSocket endpoint: ws://localhost:3000/chat/room/general?username=Alice room: Ws('/room/:roomId') .message(ClientMessage) .handle(async ({ messages, send, params, query }) => { const { roomId } = params const username = query.username || 'Anonymous' // Get the room const room = rooms.getRoom(roomId) // Subscribe to room messages - forward to this client const unsubscribe = room.subscribe((msg) => send(msg)) // Announce join rooms.announceJoin(roomId, username) try { // Process incoming messages from this client for await (const msg of messages) { const chatMsg: ChatMessage = { type: msg.type === 'message' ? 'message' : 'typing', username, content: msg.content, timestamp: new Date().toISOString(), } // Publish to all subscribers (including self) room.publish(chatMsg) } } finally { // Client disconnected - cleanup unsubscribe() rooms.announceLeave(roomId, username) } }), }), }) ``` The Ws route factory creates WebSocket endpoints. The handler receives an async iterable of messages and a send function for responses. Message Validation Use .message() with a Zod schema to validate incoming messages: Files srccontrollerschat.ts modelsroom.ts schemasmessage.ts servicesroom-service.ts index.ts srccontrollerschat.ts modelsroom.ts schemasmessage.ts servicesroom-service.ts index.ts src/schemas/message.tsTypeScript ```typescript import { z } from 'zod' // Incoming message from client export const ClientMessage = z.object({ type: z.enum(['message', 'typing']), content: z.string().optional(), }) export type ClientMessage = z.infer ``` Invalid messages are rejected automatically. The handler only receives validated, typed messages. Room Management Services manage shared state across WebSocket connections. This service tracks clients per room and handles broadcasting: Files srccontrollerschat.ts modelsroom.ts schemasmessage.ts servicesroom-service.ts index.ts srccontrollerschat.ts modelsroom.ts schemasmessage.ts servicesroom-service.ts index.ts src/services/room-service.tsTypeScript ```typescript import { defineService } from '@justscale/core' import type { ChatMessage } from '../models/room' type MessageHandler = (msg: ChatMessage) => void interface RoomInstance { name: string /** Subscribe to room messages. Returns unsubscribe function. */ subscribe(handler: MessageHandler): () => void /** Publish a message to all subscribers. */ publish(msg: ChatMessage): void } /** * Room service manages chat rooms with pub/sub messaging. * * Each room provides subscribe/publish for real-time messages. * In production, this would be backed by PostgreSQL LISTEN/NOTIFY * or Redis pub/sub for multi-instance support. */ export class RoomService extends defineService({ inject: {}, factory: () => { // In-memory room storage with subscriber sets const rooms = new Map>() function getOrCreateRoom(roomId: string): Set { let subscribers = rooms.get(roomId) if (!subscribers) { subscribers = new Set() rooms.set(roomId, subscribers) } return subscribers } return { /** * Get a room instance with subscribe/publish capabilities. */ getRoom(roomId: string): RoomInstance { const subscribers = getOrCreateRoom(roomId) return { name: roomId, subscribe(handler: MessageHandler) { subscribers.add(handler) return () => subscribers.delete(handler) }, publish(msg: ChatMessage) { for (const handler of subscribers) { handler(msg) } }, } }, /** * Announce a user joining. */ announceJoin(roomId: string, username: string): void { const room = this.getRoom(roomId) room.publish({ type: 'joined', username, content: undefined, timestamp: new Date().toISOString(), }) }, /** * Announce a user leaving. */ announceLeave(roomId: string, username: string): void { const room = this.getRoom(roomId) room.publish({ type: 'left', username, content: undefined, timestamp: new Date().toISOString(), }) // Cleanup empty rooms const subscribers = rooms.get(roomId) if (subscribers && subscribers.size === 0) { rooms.delete(roomId) } }, } }, }) {} ``` Server Setup WebSocket routes work with @justscale/core/cluster. WebSocket connections share the same HTTP port: Files srccontrollerschat.ts modelsroom.ts schemasmessage.ts servicesroom-service.ts index.ts srccontrollerschat.ts modelsroom.ts schemasmessage.ts servicesroom-service.ts index.ts src/index.tsTypeScript ```typescript import JustScale from '@justscale/core' import { defaultHttpConfig } from '@justscale/http/testing' import { ChatController } from './controllers/chat' import { RoomService } from './services/room-service' const app = JustScale() .add(defaultHttpConfig) .add(RoomService) .add(ChatController) .build() await app.serve() console.log('Chat server running on http://localhost:3000') console.log( 'WebSocket: ws://localhost:3000/chat/room/:roomId?username=YourName', ) ``` Handler Context WebSocket handlers receive a context object with: messages - Async iterable of incoming messages (typed via .message()) send - Function to send messages to the client params - URL path parameters (e.g., roomId) query - URL query parameters (e.g., username) Middleware context - Properties added by .use() Using the Chat Connect with any WebSocket client: JavaScript ```javascript // Browser const ws = new WebSocket('ws://localhost:6142/chat/room/general?username=Alice'); ws.onmessage = (event) => { const msg = JSON.parse(event.data); console.log(`[${msg.username}]: ${msg.content}`); }; // Send a message ws.send(JSON.stringify({ type: 'message', content: 'Hello everyone!' })); // Typing indicator ws.send(JSON.stringify({ type: 'typing' })); ``` With Authentication Chain requireAuth guard to protect WebSocket endpoints: TypeScript ```typescript import { Ws } from '@justscale/websocket'; import { requireAuth } from '@justscale/auth'; Ws('/chat/:roomId') .guard(requireAuth) // Validates token from query param or header .message(MessageSchema) .handle(async ({ messages, send, user }) => { // user is authenticated for await (const msg of messages) { send({ from: user.email, ...msg }); } }); ``` Tokens can be passed via Authorization header or ?token= query parameter. Next Steps Request Handling Middleware Cluster --- # Rooms & Broadcasting URL: https://justscale.sh/docs/websocket/rooms Rooms & Broadcasting Implement real-time rooms with pub/sub channels Real-time applications often need to group connections into "rooms" and broadcast messages to all members. JustScale combines @justscale/corewith WebSocket for scalable, cluster-aware rooms. Room Architecture The recommended pattern uses channels as the pub/sub layer: Channels - Handle message distribution (pub/sub) Service - Business logic, room state, user tracking Controller - WebSocket endpoints, connect channels to clients Files room.model.tsroom-channels.tschat-service.ts room.model.tsroom-channels.tschat-service.ts room.model.tsTypeScript ```typescript import { defineModel, field } from '@justscale/core/models'; // The domain entity rooms are keyed on — services and channels // take `Ref`, never a raw string ID. export class Room extends defineModel({ name: 'Room', fields: { name: field.string().max(100), }, }) {} ``` Basic Room Implementation A simple chat room that joins users and broadcasts messages: chat-controller.tsTypeScript ```typescript import { createController } from '@justscale/core'; import { Ws } from '@justscale/websocket'; import { z } from 'zod'; import { ChatService } from './chat-service'; import { Room } from './room.model'; const MessageSchema = z.object({ type: z.enum(['message', 'typing']), content: z.string().optional(), }); export const ChatController = createController('/chat', { inject: { chat: ChatService }, routes: (services) => ({ room: Ws('/room/:roomId') .message(MessageSchema) .handle(async ({ messages, send, params, query }) => { // Boundary: raw path param -> typed reference. const room = Room.ref`${params.roomId}`; const username = query.username || 'Anonymous'; // Subscribe to room events const subscription = services.chat.subscribe(room); // Broadcast join services.chat.broadcast(room, { type: 'join', username, timestamp: Date.now(), }); // Forward room messages to client (concurrent) const forwardMessages = async () => { for await (const msg of subscription) { send(msg); } }; // Handle client messages const handleMessages = async () => { for await (const msg of messages) { services.chat.broadcast(room, { type: msg.type, username, content: msg.content, timestamp: Date.now(), }); } }; // Run both concurrently until one exits await Promise.race([forwardMessages(), handleMessages()]); // Cleanup: unsubscribe and announce leave subscription.unsubscribe(); services.chat.broadcast(room, { type: 'leave', username, timestamp: Date.now(), }); }), }), }); ``` Tracking Room Members To show who's online, track members in the service: Files chat-service-members.tscontroller-with-members.ts chat-service-members.tscontroller-with-members.ts chat-service-members.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import type { Ref } from '@justscale/core/models'; import { RoomChannels, type RoomMessage } from './room-channels'; import { Room } from './room.model'; interface RoomMember { username: string; joinedAt: number; } export class ChatService extends defineService({ inject: { channels: RoomChannels }, factory: ({ channels }) => { // Track members per room (this node only). Keyed by the room's // identifier so we can hash in a Map; domain callers still pass Ref. const members = new Map>(); return { subscribe: (room: Ref) => channels.subscribe(room), broadcast: (room: Ref, msg: RoomMessage) => channels.publish(room, msg), join(room: Ref, username: string): Disposable { const key = room.identifier; if (!members.has(key)) { members.set(key, new Map()); } members.get(key)!.set(username, { username, joinedAt: Date.now(), }); return { [Symbol.dispose]: () => { members.get(key)?.delete(username); if (members.get(key)?.size === 0) { members.delete(key); } }, }; }, getMembers(room: Ref): RoomMember[] { return Array.from(members.get(room.identifier)?.values() ?? []); }, }; }, }) {} ``` Private Messages For direct messages between users, create user-specific channels: Files dm-service.tsdm-controller.ts dm-service.tsdm-controller.ts dm-service.tsTypeScript ```typescript import { createChannels, defineService } from '@justscale/core'; import type { Ref } from '@justscale/core/models'; import { User } from './user.model'; interface DirectMessage { from: Ref; content: string; timestamp: number; } // Channel per user for their DMs — keyed by `Ref` via prefix. const UserChannels = createChannels({ prefix: 'user:' }); export class DMService extends defineService({ inject: { channels: UserChannels }, factory: ({ channels }) => ({ // Subscribe to your own DM channel subscribeToMessages(user: Ref) { return channels.subscribe(user); }, // Send DM to another user sendMessage(to: Ref, from: Ref, content: string) { channels.publish(to, { from, content, timestamp: Date.now(), }); }, }), }) {} ``` Cluster-Aware Rooms For multi-node deployments, use channel hooks to broadcast across the cluster via the event bus: Files cluster-channels.tscluster-event-handler.ts cluster-channels.tscluster-event-handler.ts cluster-channels.tsTypeScript ```typescript import { createChannels } from '@justscale/core'; import { createEventBus } from '@justscale/event'; import { z } from 'zod'; import { Room } from './room.model'; import type { RoomMessage } from './types'; // Event bus for cross-cluster messaging. The wire schema carries the // room's identifier as a string — references get rebuilt at the // boundary (hook / handler) with `Room.ref`${...}``. const RoomPayload = z.object({ roomKey: z.string(), type: z.enum(['message', 'join', 'leave']), // ...other RoomMessage fields }); export const ChatEvents = createEventBus({ 'room.message': RoomPayload, 'room.join': RoomPayload, 'room.leave': RoomPayload, }); // Channels with cluster hooks. The hook is the infra boundary — pass // the raw key across the wire, rebuild a Ref on the other side. export const RoomChannels = createChannels().withHooks({ onPublish: (key, msg) => { ChatEvents.emit(`room.${msg.type}`, { roomKey: key, ...msg }); }, }); ``` 💡Tip deliverRemote() delivers to local subscribers without triggering the onPublish hook, preventing infinite loops. Room Presence For presence features (online status, last seen), combine member tracking with periodic heartbeats: presence-service.tsTypeScript ```typescript import { defineService } from '@justscale/core'; import type { Ref } from '@justscale/core/models'; import { User } from './user.model'; interface PresenceInfo { status: 'online' | 'away' | 'busy'; lastSeen: number; } export class PresenceService extends defineService({ inject: {}, factory: () => { // Keyed by user identifier so we can Map.get; callers pass Ref. const presence = new Map(); return { setPresence(user: Ref, status: PresenceInfo['status']) { presence.set(user.identifier, { status, lastSeen: Date.now(), }); }, heartbeat(user: Ref) { const info = presence.get(user.identifier); if (info) { info.lastSeen = Date.now(); } }, getPresence(user: Ref): PresenceInfo | null { return presence.get(user.identifier) ?? null; }, removePresence(user: Ref) { presence.delete(user.identifier); }, // Clean up stale entries (call periodically) cleanup(maxAge = 60000) { const now = Date.now(); for (const [key, info] of presence) { if (now - info.lastSeen > maxAge) { presence.delete(key); } } }, }; }, }) {} ``` Excluding Sender Sometimes you don't want to echo messages back to the sender. Track the sender's subscription and filter: exclude-sender.tsTypeScript ```typescript // Option 1: Filter in the forwarding loop const forwardMessages = async () => { for await (const msg of subscription) { // Don't echo own messages if (msg.username !== username) { send(msg); } } }; // Option 2: Send directly to sender, broadcast to others. // `room` here is the Ref built at the controller boundary. services.chat.broadcast(room, { type: 'message', username, content: msg.content, timestamp: Date.now(), }); // Send confirmation to sender only send({ type: 'sent', messageId: generateId() }); ``` Next Steps Contextual Controllers Channels Cluster --- # JustScale vs Encore URL: https://justscale.sh/compare/justscale-vs-encore Comparison JustScale vs Encore Both infer infrastructure from code, but draw the line differently. JustScale and Encore both let you describe a backend in code and let the framework handle plumbing, but their scope differs. Encore is an infrastructure-from-code platform: you declare resources (databases, pub/sub, cron, secrets) in code and Encore provisions and wires the cloud infrastructure, with an optional development platform and dashboard. JustScale is a framework that stays inside your application: it provides durable processes, an ID-free domain model, and distributed primitives through swappable adapters, and leaves provisioning to you. Choose Encore when you want the platform to provision and manage cloud infrastructure and you like its integrated workflow and dashboard. Choose JustScale when you want durable workflows and a strict domain model without adopting a platform, keeping your infrastructure choices behind adapters you control. JustScale vs Encore at a glance AspectJustScaleEncore Primary scopeApplication framework (durable processes + DDD + transport)Infrastructure-from-code platform that provisions cloud resources InfrastructureBring your own, wired via adapters (Postgres, Redis, ...)Declared in code and provisioned by Encore Durable workflowsFirst-class durable processes with signals and timersPub/sub and cron primitives; not a durable-process model Domain modelingID-free domain, type-states, Locked mutationsUnopinionated domain modeling Platform lock-inNone; framework onlyTighter coupling to the Encore platform/tooling Dashboard / toolingUse your own observability (OpenTelemetry)Built-in dashboard and development tooling Choose JustScale when +You want durable workflows and a strict domain model, but want to own your infrastructure choices. +You want auth, queryable permissions, OpenAPI, and a testing harness in the framework, without adopting a platform. +You prefer a framework you can drop into any deployment without platform coupling. +Distributed primitives (locks, channels) and durable processes are central to your design. Choose Encore when +You want the platform to provision and manage cloud infrastructure for you. +You like an integrated dashboard and development workflow out of the box. +Infrastructure-from-code provisioning is the main value you are after. Switching from Encore →Encore services map to JustScale services and controllers. →Resources you declared for Encore to provision (databases, caches) become adapters you wire at bootstrap. →Cron and pub/sub flows often become durable processes and channels. →You take over provisioning (the infrastructure Encore managed) in exchange for no platform coupling. Frequently asked questions Does JustScale provision cloud infrastructure like Encore? No. JustScale is a framework, not a provisioning platform. You bring your own infrastructure and wire it through adapters; there is no platform coupling. What does JustScale add that Encore does not emphasize? A first-class durable-process model (signals, timers, race) and a strict ID-free domain with compile-time type-states. Learn more Repositories Durable Processes PostgreSQL Quick Start → --- # JustScale vs Inngest & Restate URL: https://justscale.sh/compare/justscale-vs-inngest Comparison JustScale vs Inngest & Restate Durable functions as a hosted service vs a framework primitive. JustScale, Inngest, and Restate all make multi-step, long-running work durable, but they live at different layers. Inngest is an event-driven, largely hosted platform for durable step functions, popular in serverless setups where retries and step memoization are handled for you. Restate is a durable-execution runtime (a sidecar/server) that makes handlers durable. JustScale folds durability into the application framework itself: processes are plain async code, suspend on typed signals or timers, and persist to a database you already run, with no external service or runtime. Choose Inngest or Restate when you want a dedicated, hosted (or sidecar) durable-function layer, especially in serverless or polyglot environments. Choose JustScale when you want durable workflows as a native part of a typed, DDD-oriented backend, sharing the same runtime, types, and database as the rest of your application. JustScale vs Inngest & Restate at a glance AspectJustScaleInngest & Restate Deployment modelIn your app; state in your databaseInngest: hosted/serverless event platform. Restate: durable runtime/sidecar Programming modelPlain async code compiled to durable processesStep functions / durable handlers via SDK TriggeringTyped, path-based signals + HTTP/CLI/WS controllersEvents (Inngest) / durable RPC handlers (Restate) InfrastructureDatabase you already runA hosted service or a separate runtime to operate Domain integrationShares DI, types, ID-free domain, and locksExternal to your domain model LanguagesTypeScriptMultiple, depending on platform Choose JustScale when +You want durable workflows native to a typed, domain-driven backend, not a separate service. +You want a complete framework too — multi-transport controllers, auth, permissions, OpenAPI, and testing — alongside the durable layer. +You prefer to keep durable state in a database you already operate. +Signals, locks, and channels integrated with your domain are valuable to you. Choose Inngest & Restate when +You run serverless and want a hosted platform to manage retries and step state (Inngest). +You want a language-agnostic durable runtime as a sidecar/server (Restate). +You prefer an external durable layer decoupled from your application framework. Switching from Inngest & Restate →Inngest steps and Restate durable handlers become steps inside a JustScale process handler. →Event triggers map to JustScale signals or controller routes. →Built-in retries become explicit control flow or service-level retry policy inside the process. →You drop the external platform/runtime; durable state moves into your database. Frequently asked questions Is JustScale hosted like Inngest? No. JustScale runs inside your application and persists durable state to your own database. There is no hosted service to sign up for. How does JustScale differ from Restate? Restate is a language-agnostic durable runtime you operate alongside your app. JustScale builds durability into a TypeScript framework, sharing your types, DI, and database. Learn more Durable Processes Signals Channels Quick Start → --- # JustScale vs NestJS URL: https://justscale.sh/compare/justscale-vs-nestjs Comparison JustScale vs NestJS Two full TypeScript backend frameworks, two very different bets. JustScale and NestJS are both full TypeScript backend frameworks with dependency injection and controllers, but they sit on opposite sides of a technology shift. NestJS is mature and has a huge ecosystem, but its model is built on the legacy experimental decorators and runtime reflect-metadata that predate today’s standards, and it remains CommonJS-oriented. JustScale is ESM-native with no decorators and no runtime metadata reflection. JustScale also bundles far more than routing into its core: one controller model spanning HTTP, WebSocket, SSE, gRPC, CLI, and events; a domain layer with an ID-free model and compile-time type-states; built-in authentication and declarative, queryable model-level permissions; automatic OpenAPI generation; OpenTelemetry; a multi-instance testing harness; hot reload in development; and durable processes with distributed-safe locks and channels. Choose NestJS for the largest ecosystem and the deepest hiring pool; choose JustScale for a modern, ESM-native framework where all of that is built in rather than assembled. JustScale vs NestJS at a glance AspectJustScaleNestJS Programming modelFunction-based DI (defineService); no decorators, no runtime metadata reflectionLegacy experimental decorators + reflect-metadata (emitDecoratorMetadata) Module systemESM-native ("type": "module") throughoutCommonJS-oriented; ESM support remains awkward TransportHTTP, WebSocket, SSE, gRPC, CLI, and events from one controller modelHTTP plus a separate microservices/transport layer Domain modelingID-free domain; entities are their own references; mutations require LockedUnopinionated; typically string IDs + an ORM of your choice Type safetyType-states: data shape gates what code can do, checked by the compilerStandard TypeScript types; runtime validation via pipes/DTOs Auth & access controlBuilt-in authentication + declarative, queryable model-level permissionsAssembled from Passport, guards, and libraries API docs & testingOpenAPI from your controllers; multi-instance test harness; hot reload@nestjs/swagger; Jest + utilities; restart on change Distributed primitivesLocks, channels, and cluster coordination are first-classProvided by the ecosystem (Redis, microservices transport) Durable workflowsBuilt in: plain async code compiled into durable processes with signals, race, and delayNot built in; add a queue (BullMQ) or external engine Ecosystem & maturityYoung; smaller surface, fewer integrationsVery mature; huge module ecosystem and community Choose JustScale when +You want a modern, ESM-native framework without legacy decorators or runtime metadata reflection. +You want one integrated framework — multi-transport controllers, a domain layer, auth, permissions, OpenAPI, observability, and testing — instead of assembling libraries. +You have long-running, stateful workflows (orders, onboarding, sagas) that must survive restarts. +You want domain code that never touches string IDs and is safe by construction across instances. Choose NestJS when +You need the largest ecosystem and the deepest pool of developers who already know the framework. +You prefer a decorator/module style, or you have existing Nest code and integrations. +Your workloads are mostly request/response and you already have a queue for background work. Switching from NestJS →NestJS providers map to JustScale services: an @Injectable() class becomes a defineService({ inject, factory }) definition. →NestJS controllers map to JustScale controllers, but routes are defined with route-builder factories (Get/Post/...) instead of method decorators. →Background jobs and queues that you ran on BullMQ usually become durable processes, removing the separate worker/queue infrastructure. →Guards and interceptors map to JustScale guards and middleware. Frequently asked questions Is NestJS outdated? NestJS is actively maintained and widely used, but its core depends on the legacy experimental decorators and runtime reflect-metadata that predate the current decorator standard, and it remains CommonJS-oriented. JustScale takes a modern, ESM-native, decorator-free approach. Is JustScale a replacement for NestJS? It can be, for new projects that want a modern ESM-native framework with durable workflows, a pure domain model, auth, permissions, and OpenAPI built in. NestJS remains the safer pick when ecosystem size and team familiarity dominate. Does JustScale use decorators like NestJS? No. JustScale uses function-based dependency injection and route-builder factories, avoiding decorators and reflect-metadata entirely. Can JustScale handle background jobs without BullMQ? Yes. Durable processes replace most queue-based background work; they suspend on signals or timeouts and resume after restarts. Learn more Migrating from NestJS → Services Controllers Durable Processes Quick Start → --- # JustScale vs Temporal URL: https://justscale.sh/compare/justscale-vs-temporal Comparison JustScale vs Temporal Durable execution as a framework feature vs a dedicated platform. JustScale and Temporal both make long-running workflows survive crashes and restarts, but they package durability very differently. Temporal is a dedicated durable-execution platform: a separate server cluster plus worker processes, with polyglot SDKs, mature versioning, retries, and a powerful observability UI. JustScale runs durable processes in-process inside your application, persisting state to a database you already run (such as PostgreSQL), with no separate cluster to operate. Choose Temporal when you need battle-tested durability at very large scale, advanced workflow versioning, and dedicated operational tooling, or when you work across multiple languages. Choose JustScale when you want durable workflows without standing up and operating a separate system, written as plain async code in the same runtime and type system as the rest of your app. JustScale vs Temporal at a glance AspectJustScaleTemporal ArchitectureIn-process; durable state persisted to your database (e.g. Postgres)Separate Temporal server/cluster + worker fleet AuthoringPlain async code compiled into a durable state machineWorkflow + activity functions using the SDK programming model Infrastructure to operateNone beyond the database you already runA Temporal cluster (self-hosted) or Temporal Cloud Signals / eventsPath-based, typed signals integrated with the domainSignals and queries via the SDK Versioning & migrationYounger; evolving storyMature workflow versioning and patching ObservabilityVia your app + OpenTelemetryDedicated Web UI for workflow history and replay LanguagesTypeScriptGo, Java, TypeScript, Python, .NET, PHP Scale & track recordYoungProven at very large scale Choose JustScale when +You want durable workflows without operating a separate server and worker fleet. +You want durability inside a full backend framework — controllers, domain layer, auth, OpenAPI, and testing — not a standalone execution engine. +Your workflows live alongside your domain and should share its types, DI, and database. +A single TypeScript runtime and "no extra infrastructure" matter more than a dedicated UI. Choose Temporal when +You need proven durability at very large scale with mature versioning and replay tooling. +You operate across multiple languages and want one durable-execution backbone. +A dedicated workflow observability UI is a hard requirement. Switching from Temporal →A Temporal workflow becomes a JustScale process: the handler is plain async code instead of the SDK workflow API. →Activities (side-effecting steps) become ordinary service calls inside the process. →Temporal signals map to JustScale path-based signals; timers map to delay. →Instead of deploying workers against a Temporal cluster, you run your app; durable state lives in your database. Frequently asked questions Does JustScale need a separate server like Temporal? No. Durable processes run inside your application and persist state to a database you already operate, so there is no separate cluster or worker fleet. Is JustScale as battle-tested as Temporal for durability? No. Temporal is proven at very large scale with mature versioning and replay tooling. JustScale trades some of that maturity for simpler operations and tighter integration with your domain and types. How are workflows written in JustScale vs Temporal? In JustScale a workflow is plain async code that the compiler transforms into a durable state machine. In Temporal you write workflow and activity functions against the SDK programming model. Learn more Migrating from Temporal → Durable Processes Signals Process Compiler Quick Start → --- # Migrating from NestJS to JustScale URL: https://justscale.sh/migrate/from-nestjs Migration guide Migrating from NestJS to JustScale Migrating from NestJS to JustScale is mostly a one-to-one translation: NestJS providers become JustScale services, controllers stay controllers, and modules become explicit app composition. The biggest change is conceptual rather than mechanical — decorators and reflect-metadata are replaced by function-based dependency injection, and background jobs you ran on a queue typically become durable processes. This guide walks through each piece in the order you will hit it: services, controllers and routes, app wiring, validation, guards, and background work. 1.Providers become services A NestJS @Injectable() provider becomes a defineService with an inject map and a factory. Constructor injection is replaced by the injected dependencies passed to the factory. NestJS ``` @Injectable() export class UserService { constructor(private readonly users: UserRepository) {} findByEmail(email: string) { return this.users.findOne({ where: { email } }); } } ``` JustScale ``` export class UserService extends defineService({ inject: { users: UserRepository }, factory: ({ users }) => ({ findByEmail: (email: string) => users.findOne(User.fields.email.eq(email)), }), }) {} ``` 2.Controllers and routes Controllers stay controllers, but routes are defined with route-builder factories instead of method decorators. Path params can be turned into typed model references with .types(). NestJS ``` @Controller('users') export class UsersController { constructor(private readonly svc: UserService) {} @Get(':id') get(@Param('id') id: string) { return this.svc.findById(id); } } ``` JustScale ``` import { createController } from '@justscale/core'; import { Get } from '@justscale/http'; export const UsersController = createController({ inject: { svc: UserService }, routes: () => ({ get: Get('/users/:user').types({ user: User }).handle(({ user }) => user), }), }); ``` 3.Modules become app composition Instead of @Module metadata, you compose the app explicitly. Add services and controllers to a JustScale() app; there is no module graph to maintain. NestJS ``` @Module({ providers: [UserService], controllers: [UsersController], }) export class AppModule {} ``` JustScale ``` import JustScale from '@justscale/core'; export const app = JustScale() .add(UserService) .add(UsersController); ``` 4.DTOs and pipes become schema validation Validation pipes and class-validator DTOs become a schema on the route. The handler receives a typed, validated body. NestJS ``` export class CreateUserDto { @IsEmail() email: string; } @Post() create(@Body() dto: CreateUserDto) { /* ... */ } ``` JustScale ``` import { Post } from '@justscale/http'; import { z } from 'zod'; create: Post('/users') .body(z.object({ email: z.string().email() })) .handle(({ body }) => { /* body is typed + validated */ }), ``` 5.Background jobs become durable processes Queue-based background work (for example a BullMQ processor) usually becomes a durable process: plain async code that suspends on signals or timeouts and resumes after a restart, with no separate worker or queue. NestJS + BullMQ ``` @Processor('orders') export class OrderProcessor { @Process() async handle(job: Job) { await chargeCard(job.data.orderId); } } ``` JustScale ``` import { createProcess } from '@justscale/core/process'; export const orderFulfillment = createProcess({ path: '/order/:order/fulfillment', inject: { payments: PaymentService }, async handler({ payments }, { order }) { await payments.charge(order); // survives restarts }, }); ``` Frequently asked questions How long does migrating from NestJS take? Most of the work is mechanical: providers to services and controllers to route-builder controllers. The conceptual shift is dropping decorators for function-based DI and moving queue jobs to durable processes. Do I have to rewrite my NestJS guards? No rewrite of the logic is needed; NestJS guards map to JustScale guards and interceptors map to middleware. Can I keep BullMQ during migration? Yes. You can migrate incrementally and convert queue jobs to durable processes one at a time. Learn more JustScale vs NestJS Services Controllers Durable Processes Quick Start → --- # Migrating from Temporal to JustScale URL: https://justscale.sh/migrate/from-temporal Migration guide Migrating from Temporal to JustScale Migrating from Temporal to JustScale means moving durable workflows out of a separate cluster and into your application. A Temporal workflow becomes a JustScale durable process written as plain async code; activities become ordinary service calls; signals and timers map directly onto JustScale signals and delay. Durable state lives in a database you already run, so there is no Temporal server or worker fleet to operate. The steps below cover the workflow body, activities, signals, timers, and deployment. 1.Workflows become durable processes A Temporal workflow becomes a process whose handler is plain async code. Waiting for a signal or a timeout is expressed with race(): the first of a signal or a delay wins, just like a workflow that awaits a signal with a timer. Temporal ``` export async function orderWorkflow(orderId: string) { const paid = await condition(() => signalReceived, '3 days'); return paid ? { status: 'paid' } : { status: 'timed-out' }; } ``` JustScale ``` import { createProcess, race, signal, delay } from '@justscale/core/process'; export const orderFulfillment = createProcess({ path: '/order/:order/fulfillment', inject: { signals: OrderSignals }, async handler({ signals }, { order }) { const r = race(); switch (true) { case signal(r, signals.paymentConfirmed): return { status: 'paid', txId: r.txId }; case delay.days(r, 3): return { status: 'timed-out' }; } }, }); ``` 2.Activities become service calls Temporal activities (the side-effecting, retryable steps) become ordinary injected service calls inside the process handler. Retry behaviour lives in the service or the control flow rather than in activity options. Temporal ``` const { chargeCard } = proxyActivities({ startToCloseTimeout: '1 minute', }); await chargeCard(orderId); ``` JustScale ``` // payments is injected into the process await payments.charge(order); ``` 3.Signals become defineSignals Temporal signal handlers become path-based, typed signals declared with defineSignals. The path is the topic, and .types() ties a path param to a model so the process receives a locked entity. Temporal ``` setHandler(paymentConfirmed, (txId: string) => { signalReceived = true; }); ``` JustScale ``` export class OrderSignals extends defineSignals((signal) => ({ paymentConfirmed: signal('/order/:order/payment/confirmed') .data<{ txId: string }>() .types({ Order }), })) {} ``` 4.Timers become delay; deployment loses the cluster Temporal timers (sleep / workflow.sleep) become delay inside a race, as shown above. For deployment, there is no Temporal cluster or worker fleet: you run your application, and durable process state is persisted to your database (for example PostgreSQL). Frequently asked questions Does JustScale replace the Temporal server and workers? Yes. Durable processes run inside your application and persist state to your own database, so there is no separate Temporal cluster or worker fleet to operate. How do Temporal signals map to JustScale? Temporal signal handlers become path-based, typed signals declared with defineSignals; the process awaits them with race(), alongside timers expressed as delay. Is JustScale a drop-in replacement for Temporal? For workflows that fit a single TypeScript application it is a strong fit. Temporal still leads on very large scale, mature versioning, and dedicated replay/observability tooling. Learn more JustScale vs Temporal Durable Processes Signals Runtime & Testing Quick Start →