Welcome
In your grand search to find the perfect, modern (ESM-forward, stage 3 decorators) Typescript web framework, we are humbled that you have stumbled upon us. We are new to the game, having just officially released the stable, v2 API in November of 2025, but we believe our offering to you was worth the wait.
Dream and Psychic have been in development since early 2023 and in production use at RVOHealth (our employer and sponsor of the entire Psychic ecosystem, which consists of 8 npm packages, these guides, the Psychic RAG, and demo application) since December 2023. Inspired by over a decade of Ruby on Rails experience, the framework has evolved into a full-featured, batteries-included solution refined through real-world production use.
What is Dream?
At the heart of most web applications is a database, with, at least generally speaking, a tightly-defined set of table schemas to guard the integrity of its data. Dream follows the conventional Active Record practices for modeling data, but provides a very powerful TypeScript-driven set of features to unleash powerful autocomplete mechanisms that make even the most dense applications possible to navigate.
const deco = new Decorators<typeof User>()
@deco.SoftDelete()
export default class User extends ApplicationModel {
public get table() {
return 'users' as const
}
public id: DreamColumn<User, 'id'>
public name: DreamColumn<User, 'name'>
public createdAt: DreamColumn<User, 'createdAt'>
public updatedAt: DreamColumn<User, 'updatedAt'>
public deletedAt: DreamColumn<User, 'deletedAt'>
@deco.Validates('contains', '@')
@deco.Validates('presence')
public email: string
@deco.Validates('length', { min: 4, max: 18 })
public passwordDigest: string
@deco.Virtual()
public password?: string | null
public passwordDigest: DreamColumn<User, 'passwordDigest'>
@deco.BeforeSave()
public async hashPass() {
if (this.password) this.passworDigest = await argon2.hash(this.password)
this.password = undefined
}
public async checkPassword(password: string) {
if (!this.passworDigest) return false
return await argon2.verify(this.passworDigest, password)
}
}
Powerful associations
In addition to powerful decorators for describing validations and custom scoping on your models, Dream also provides a powerful association layer, enabling you to describe rich, intimate associations with elegant simplicity:
const deco = new Decorators<typeof Host>()
export default class Host extends ApplicationModel {
...
@deco.BelongsTo('User')
public user: User
public userId: DreamColumn<Host, 'userId'>
@deco.HasMany('HostPlace')
public hostPlaces: HostPlace[]
@deco.HasMany('Place', { through: 'hostPlaces' })
public places: Place[]
@deco.HasMany('LocalizedText', { polymorphic: true, on: 'localizableId', dependent: 'destroy' })
public localizedTexts: LocalizedText[]
@deco.HasOne('LocalizedText', {
polymorphic: true,
on: 'localizableId',
and: { locale: DreamConst.passthrough },
})
public currentLocalizedText: LocalizedText
}
See the Dream guides for more information on modeling with Dream
Serialization
Dream provides first-class serialization support, empowering devs to easily bind serializers to models, and then exploit those connections throughout your application.
class User extends ApplicationModel {
public get serializers(): DreamSerializers<User> {
return {
default: 'UserSerializer',
summary: 'UserSummarySerializer',
}
}
}
// somewhere, usually in a controller...
await User.preloadFor('summary').findOrFail(this.castParam('id', 'uuid'))
To learn more about automatic preloading, see our preloadFor documentation.
What is Psychic?
Psychic is a fully-featured, TypeScript-driven web framework built on Express. It leverages the MVC (Model, View, Controller) paradigm, providing the Dream ORM under the hood for data modeling. Philosophically, Psychic and Dream use opinionated conventions to enable seamless development, allowing beautiful type integrations to flow throughout your app with ease. By following a strict convention-over-configuration philosophy throughout our design, we enable you to write less while following best practice design concepts.
Routing
Psychic provides an expressive routing layer to your controllers:
// conf/routes.ts
import V1HostPlacesController from '@controllers/V1/Host/PlacesController.js'
import { PsychicRouter } from '@rvoh/psychic'
export default function routes(r: PsychicRouter) {
r.namespace('v1', r => {
r.namespace('guest', r => {
r.resources('places', { only: ['index', 'show'] })
// Alternatively, could have written the routes explicitly:
// r.get('places', V1GuestPlacesController, 'index')
// r.get('places/:id', V1GuestPlacesController, 'show')
})
r.namespace('host', r => {
r.resources('localized-texts', { only: ['update', 'destroy'] })
r.resources('places', r => {
// nested resource
r.resources('rooms')
// add an action on an individual resource beyond show/create/update/destroy
r.post('rennovate', V1HostPlacesController, 'rennovate')
r.collection(r => {
// add an action on the collection beyond index
r.post('create-from-template', V1HostPlacesController, 'createFromTemplate')
})
})
})
})
}
Use psy routes to see what these resolve to:
pnpm psy routes
Example output:
GET /v1/guest/places V1/Guest/Places#index
GET /v1/guest/places/:id V1/Guest/Places#show
PUT /v1/host/localized-texts/:id V1/Host/LocalizedTexts#update
PATCH /v1/host/localized-texts/:id V1/Host/LocalizedTexts#update
DELETE /v1/host/localized-texts/:id V1/Host/LocalizedTexts#destroy
GET /v1/host/places/:placeId/rooms V1/Host/Places/Rooms#index
POST /v1/host/places/:placeId/rooms V1/Host/Places/Rooms#create
PUT /v1/host/places/:placeId/rooms/:id V1/Host/Places/Rooms#update
PATCH /v1/host/places/:placeId/rooms/:id V1/Host/Places/Rooms#update
GET /v1/host/places/:placeId/rooms/:id V1/Host/Places/Rooms#show
DELETE /v1/host/places/:placeId/rooms/:id V1/Host/Places/Rooms#destroy
POST /v1/host/places/:id/rennovate V1/Host/Places#rennovate
POST /v1/host/places/create-from-template V1/Host/Places#createFromTemplate
GET /v1/host/places V1/Host/Places#index
POST /v1/host/places V1/Host/Places#create
PUT /v1/host/places/:id V1/Host/Places#update
PATCH /v1/host/places/:id V1/Host/Places#update
GET /v1/host/places/:id V1/Host/Places#show
DELETE /v1/host/places/:id V1/Host/Places#destroy
see the Routing guide for more information on routing
Controllers
With routes defined, you can build matching controllers to add functionality to your endpoints. By default, Psychic receives and returns, so all endpoints will generally just be rendering json, if anything.
// controllers/V1/Host/PlacesController.ts
export default class V1HostPlacesController extends AuthedController {
public async create() {
const place = await Place.create(this.paramsFor(Place))
this.created(place)
}
public async index() {
const places = await Place.preloadFor('summary').all()
this.ok(places)
}
public async show() {
const place = await Place.preloadFor('default').findOrFail(this.castParam('id', 'bigint'))
this.ok(place)
}
public async update() {
const place = await Place.findOrFail(this.castParam('id', 'bigint'))
await place.update(this.paramsFor(Place))
this.noContent()
}
public async destroy() {
const place = await Place.findOrFail(this.castParam('id', 'bigint'))
await place.destroy()
this.noContent()
}
}
As you can already see above, our Dream ORM is clearly at work to keep our code so tidy. Psychic will automatically respond with a 404 for any failures caused by findOrFail, and castParam will fail with a 400 if the incoming param does not match the described schema. These design patterns enable you to get out of your own way, enabling the composition of extremely simple design patterns with powerful intuitions about your needs.
see the Controller guides for more information on implementing controllers
Openapi
Psychic uses information from the routes, models, serializers, and an @OpenAPI decorator applied to controller actions to automatically generate OpenAPI specs. Different OpenAPI specs can be generated for different namespaces (e.g.: admin route OpenAPI spec separate from client route OpenAPI spec separate from OpenAPI specs for API service endpoints). It can also infer request body shapes for models on POST, PATCH, and PUT requests, enabling automatic request body generation as well. Since model attribute types flow from the database, this means that attribute types are defined once, in the migration, and flow through the entire application to the OpenAPI spec.
This facilitates agile development and, when using a monorepo with a client web application or applications, a full behavior driven development (BDD) workflow. The resulting OpenAPI specs can then be used by mobile teams to develop the corresponding mobile features.
// controllers/V1/Host/PlacesController.ts
export default class V1HostPlacesController extends AuthedController {
// passing Place will automatically generate an openapi request body
// containing all of the "safeParams" fields from the model.
@OpenAPI(Place, {
status: 201,
responses: {
201: {
type: 'string',
},
},
})
public async create() {
const place = await ApplicationModel.transaction(async txn => {
const place = await Place.txn(txn).create(this.paramsFor(Place))
await HostPlace.txn(txn).create({ host: this.currentHost, place })
return place
})
this.created(place.id)
}
// Automatically serializes using the "default" serializer
// declared in the Place model's `serializers` getter.
@OpenAPI(Place, {
status: 200,
})
public async show() {
const place = await this.place()
this.ok(place)
}
// Passing `cursorPaginate: true` will specify an openapi document
// that renders an array of placesand the scroll "cursor". Using the
// "summary" `serializerKey` will tell openapi to use the "summary"
// serializer, declared in the Place model's `serializers` getter.
@OpenAPI(Place, {
status: 200,
cursorPaginate: true,
serializerKey: 'summary',
})
public async index() {
const places = await this.currentHost
.associationQuery('places')
.preloadFor('summary')
.cursorPaginate({ cursor: this.castParam('cursor', 'string', { allowNull: true }) })
this.ok(places)
}
}
In the above example, the create method will automatically generate an OpenAPI spec for this endpoint which responds with a 201 status and a string, but has a request body that matches all of the "safe params" on Place (see paramSafeColumns/paramUnsafeColumns for details).
The OpenAPI spec for the show method is automatically defined by the "default" serializer declared on the Place model.
Since the the OpenAPI decorator for the index method includes cursorPaginate true, the OpenAPI shape will reflect the cursorPaginate shape, with an array of results rendered using the "summary" serializer declared in the Place model's serializers getter. Alternatives to cursorPaginate for index endpoints are paginate, for traditional pagination with page numbers (slower than cursorPaginate when results may include many records) and many, for when pagination is unnecessary and you simply want to return an array of results.
You can do quite a lot with the OpenAPI decorator, so it is worth reading up on the Openapi guides to unlock the full potential of our powerful openapi integration.
Philosophy
Psychic and Dream provide an end-to-end solution for modern web applications, without getting in the way by intervening in the front end client building process. Instead, we encourage Psychic developers to use whatever front end framework they want for prototyping their app, and we make little to no interventions, setting it up whatever way they see fit. We do not provide any templating engines, or any mechanisms for rendering anything other than json data, which can be consumed by whatver API consumers need to do so.
We do, however, provide a tools for composing a robust backend, as well as the testing infrastructure to cover any set of web client integrations you desire.
Keeping DRY
Don't Repeat Yourself (DRY), is a guiding philosophy of Dream and Psychic, revealing itself in:
- model attribute types that are derived from the database, so when you write a migration that, for example, adds an enum, the one place that you define the change is in the migration, and it automatically cascades everywhere the enum is referenced or set
- OpenAPI definitions that are fleshed out automatically from the routes file and model serializers
HasOne,HasMany, andBelongsToassociation decorators that encapsulate all the complexity of an association into a single declaration that, once defined, becomes an abstraction in a well defined domain
Date and Time Handling
Always use Dream's DateTime and CalendarDate classes instead of JavaScript's native Date.
// ❌ NEVER DO THIS - JavaScript Date has timezone and parsing issues
const badDate = new Date()
const badParsed = new Date('2024-01-15')
// ✅ ALWAYS DO THIS - Use Dream's robust date/time classes
import { DateTime, CalendarDate } from '@rvoh/dream'
const timestamp = DateTime.now()
const date = CalendarDate.today()
const parsed = CalendarDate.fromISO('2024-01-15')
Dream automatically converts database timestamp fields to DateTime objects and date fields to CalendarDate objects, providing built-in math, formatting, and timezone handling. see the DateTime and CalendarDate guides for full documentation.
- powerful decorators like
@SoftDelete,@Sortable, and@ReplicaSafethat automatically and universally handle common use cases which would otherwise introduce complexity into your application - cli code generators that set up models, serializers, and controllers using best practice conventions, such as controllers inheriting from an authenticated ancestor right from the start
- advanced association patterns such as has-many-through, single table inheritance (STI), and polymorphism
- utilities like paramsFor and preloadFor, which help you stay DRY when defining new associations on your serializers, or add or edit columns on your table.
Use familiar technologies
Dream and Psychic are built on trusted open source pacakges, which not only provide robust, secure, vetted foundations, but also enable applications built on Dream and Psychic to leverage those packages (e.g. a Dream query can be turned into a Kysely query with toKysely):
- kysely (used for driving the Dream ORM)
- expressjs (drives the underlying web server)
- openapi (drives integration with front end clients)
- luxon (Dream’s DateTime is a simple repackaging of Luxon for ESM, and CalendarDate is built on DateTime)
- node-pg (used for driving the ORM)
- ioredis (adds node bindings to redis, enabling us to support both distributed websocket systems, as well as background job systems)
- bullmq (used for driving background jobs)
- socket.io (used for websockets)
- vitest (used for driving unit and feature tests)
- puppeteer (used for running a headless browser during your feature tests)
Convention over configuration
Since Psychic and Dream are meant to be used together, Psychic is well-fit to automatically absorb implicit configurations at the Dream layer, enabling you to define things once, rather than many times. These sensible expectations by the app enable you to compose with ease, and make changes that can flow through to the top level of your app without even needing to make changes.
Leverage openapi to simplify front end integration
In the modern front end javascript ecosystem, there are simply too many options for developers for us to have any desire to try taking on direct integrations with any client framework directly. By not getting in the way, we believe we provide the most flexibility, integration-wise. However, we recognize there are a few points at which front end integrations become useful, and provide the binding mechanisms to make that possible.
Auto-launching the client when a Psychic dev server starts
If you opt into a client when provisioning your Psychic app, you will automatically have a few lines of code added to your conf/app.ts file, which will automatically launch your client server whenever your app launches with NODE_ENV=development.
// conf/app.ts
export default async (psy: PsychicApp) => {
...
psy.on('server:start', async psychicServer => {
if (AppEnv.isDevelopment && AppEnv.boolean('CLIENT')) {
await PsychicDevtools.launchDevServer('client', { port: 3000, cmd: 'pnpm client' })
}
})
psy.on('server:shutdown', () => {
if (AppEnv.isDevelopment && AppEnv.boolean('CLIENT')) {
PsychicDevtools.stopDevServer('client')
}
})
}
The PsychicDevtools.launchDevServer command is extremely flexible. Providing it with a command to run and a port to expect to be occupied once the command launches will enable psychic to side-launch your client server. If you have multiple servers you need to launch, there is nothing to prevent you from adding it. Just don't forget that any server launched in the server:start hook will need a corresponding stopDevServer call in server:shutdown.
In addition, because of the flexibility provided, you are able to side-launch commands that are not in any way associated with your code repository, enabling you to achieve client integration without even enabling a client within Psychic, providing ultimate flexibility for devs.
Testing
Psychic can easily act as a standalone json web delivery system, but it also encourages certain paradigms which enable the developer to still write end-to-end tests, as well as a rich tooling system for composing unit tests. Psychic was designed with a BDD philosophy in mind, which means that the system must provide adequate tooling for spec'ing out the entire app.
As most in the javascript world are comfortable with vitest, we have built our tooling to rest comfortably on top of it, facilitating the integration of custom vitest or jest plugins of your choice without any trouble from the framework. We do, however, provide some useful extensions to make your life easier when spec'ing in Dream and Psychic.
Unit specs
Unit specs describe the behavior of your app. When practicing BDD, the unit specs are written before the functionality they describe, and enable you to test the behavior of your function from the outside in. Psychic and Dream provide special tools to enhance this process and make it seamless for you to interact with your app in a test environment.
For more information, see The Unit spec guides.
Dream (model) specs
When spec'ing your models, you can leverage special vitest extensions to make assertions simple, and can very easily test all sorts of extraneous behavior using the same suite of tools:
describe('User', () => {
describe('upon creation', () => {
context('UserSettings model creation', () => {
it('creates a user settings model, and attaches it to the user', async () => {
expect(await UserSettings.count()).toEqual(0)
const user = await createUser()
expect(await UserSettings.firstOrFail()).toMatchDreamModel(user.userSettings)
})
})
})
describe('#checkPassword', () => {
let user: User
beforeEach(async () => {
user = await createUser({ password: 'howyadoin' })
})
context('with a valid password', () => {
it('returns true', async () => {
expect(await user.checkPassword('howyadoin')).toBe(true)
})
})
context('with an invalid password', () => {
it('returns false', async () => {
expect(await user.checkPassword('invalid')).toBe(false)
})
})
})
})
Controller specs
Psychic also provides helpers to enable easy endpoint testing, making it simple to hit your routes with real requests and test the response mechanisms of your app under a variety of circumstances.
// api/spec/unit/controllers/Api/V1/UsersController.spec.ts
import { PsychicServer } from '@rvoh/psychic'
import { OpenapiSpecRequest } from '@rvoh/psychic-spec-helpers'
import createUser from '../../../../factories/UserFactory'
import { validationOpenapiPaths } from '../../../../../src/types/openapi/validation.openapi.js'
const request = new OpenapiSpecRequest<validationOpenapiPaths>()
describe('ApiV1UsersController', () => {
beforeEach(async () => {
await request.init(PsychicServer)
})
describe('GET /api/v1/users/me', () => {
async function getSession() {
return await request.session('/api/v1/signin', 204, {
data: {
email: 'how@yadoin',
password: 'password',
},
})
}
it('returns 204 with an authed user', async () => {
await createUser({ email: 'how@yadoin', password: 'password' })
const session = await getSession()
await session.get('/api/v1/users/me').expect(204)
})
it('returns 401 with no authed user', async () => {
await createUser({ email: 'how@yadoin', password: 'password' })
await request.get('/api/v1/users/me', 401)
})
})
})
Feature (end-to-end) specs
Psychic will automatically bootstrap with puppeteer as a dev dependency to provide a headless browser you can use to test a web application end-to-end. Whenever these tests run, a psychic server will automatically be started which can be used by your client application, enabling you to test your front end integration with your back end.
For more information, see The Feature spec guides.
// api/spec/features/visitor/signs-up.spec.ts
import User from '../../../src/app/models/User'
describe('visitor visits the signup page', () => {
it('allows visitor to fill sign up for a new account and then log in with the same credentials', async () => {
await visit('/signup')
await fillIn('#email', 'hello@world')
await fillIn('#password', 'mypassword')
await clickButton('sign up')
await expect(page).toMatchTextContent('Log in')
await fillIn('#email', 'hello@world')
await fillIn('#password', 'mypassword')
await clickButton('log in')
await expect(page).toMatchTextContent('DASHBOARD')
const user = await User.last()
expect(user.email).toEqual('hello@world')
expect(await user.checkPassword('mypassword')).toEqual(true)
})
})
Wondering how to get started? A good place to go next is our installation guide.
Otherwise, you may want to read up more on some of the major features provided by Dream and Psychic: