Conventions
Conventions
JSails delivers Rails-style ergonomics through convention, generators, and a declarative model DSL — not through runtime autoloading. This page explains the mental model so a new project reads like a framework, not a pile of imports.
One import, everything available
The whole data layer is reachable from a single specifier. Write it once at the top of a file and use the Active Record base, query expressions, validation, jobs, and encryption without further ceremony:
import {
BaseEntity,
query,
defineEntityValidation,
validateFields,
defineJob,
} from 'jsails';
Every subpath (jsails/database, jsails/jobs, jsails/api, jsails/validation,
…) re-exports the same surface from a browser/server-appropriate barrel — you almost
never need to remember which subpath a symbol lives in.
Models are declarative
A model extends the project-local ApplicationRecord (re-exporting BaseEntity),
so class-level queries are available without a repository. Relations, forwarding,
and nested persistence are declared with a Rails-style DSL rather than hand-rolled
decorator graphs — and the whole model surface (the base class, the DSL, and
TypeORM's decorators) comes from one specifier, jsails/models:
import {
Entity,
Column,
ApplicationRecord,
belongs_to,
has_many,
delegate,
accepts_nested_attributes_for,
encrypts,
} from 'jsails/models';
@Entity('posts')
export class Post extends ApplicationRecord {
@Column({ type: 'varchar', length: 255 })
title!: string;
belongs_to('author', { touch: true });
has_many('comments', { counter_cache: true, dependent: 'destroy' });
accepts_nested_attributes_for('comments');
delegate('author_name', { to: 'author', method: 'name' });
encrypts('body');
}
jsails/models is a convenience re-export, not a wrapper: Column/Entity are
TypeORM's own decorators (the same references), and the DSL helpers are the
jsails/database functions, so there is nothing new to learn — just one less
import to remember. The starter's app/application-record.ts re-exports the same
surface (for apps that want a project-local base), so a model may import from
either.
belongs_to/has_many/has_oneregister the relation and, per option, wire the lifecycle bridges (touch,counter_cache,dependent) — one line, not a@OneToManydecorator plus a manual subscriber.delegate(methods, { to, prefix?, allow_nil? })forwards getters and methods through a relation, bindingthisto the target and failing value-free on a missing relation unlessallow_nil: true.accepts_nested_attributes_forandencryptsare the same one-line hooks.
Generators write the imports for you
Because Node ESM requires explicit imports and has no constant-autoloading hook,
the framework writes the boilerplate instead of asking you to. jsails make:*
emits an import-complete, compilable file in the conventional directory:
jsails make:model post # models/post.ts — extends ApplicationRecord
jsails make:page about-us # pages/about-us.tsx — wired to the Layout
jsails make:api health # api/health.ts — a read-only GET route
jsails make:job send-reminder # jobs/send-reminder.ts — a Zod-validated job
jsails make:command report # commands/report.ts — a CLI command
jsails make:server-component todo # components/todo.tsx — a live server component
jsails make:serializer post # serializers/post.ts — a field serializer
jsails make:middleware cache # middleware/cache.ts — a route middleware
Each generated file follows the starter's own conventions, so it compiles against a fresh scaffold with no further editing.
Declarative plugins and resources
The same "one object is the whole thing" idea extends to plugins and API resources, so the imperative wiring is collapsed into a single declarative spec:
// A plugin as data: services, HTTP hooks, and commands without manual setup.
import { defineDeclarativePlugin } from 'jsails/extensions';
import { clockToken } from './tokens.js';
export const clock = defineDeclarativePlugin({
name: 'clock',
requires: [/* ... */],
provide: [[clockToken, { now: () => new Date().toISOString() }]],
http: (app) => app.get('/time', (c) => c.json({ now: Date.now() })),
});
// An API resource as data: serializer + store + authorize in one object.
import { defineDeclarativeResource } from 'jsails/api';
export const posts = defineDeclarativeResource({
name: 'post',
serializer: { id: number(), title: string(), body: optional(string()) },
store,
authorize: resourcePolicy({ list: require(isStaff) }),
});
// posts.serializer / posts.handlers / posts.routes / posts.authorize
defineDeclarativePlugin and defineDeclarativeResource are thin wrappers over
the existing definePlugin and defineSerializer/createResourceHandlers/
createResourceRouter primitives — they reshape a declarative object into the
imperative wiring, never reimplement the engine.
Three further shorthands collapse the remaining boilerplate:
defineStoreResource({ slug, store, serializer })(jsails/admin) builds an admin resource whoselist/get/saveare derived from aResourceStore, so a database-backed CRUD shares one store/serializer with the API side.defineSimpleComponent(name, { fields, run, render })(jsails/server-components) derives the ZodstateSchemaandwritableKeysfrom afieldsmap and collapses actions to a singlerun— the fast path for a form, toggle, or counter.defineAppConfig(appConfigSchema, config)(jsails) validates the top-level app-config shape against a Zod schema, returning typed config with value-free errors for a typo or wrong-typed field.
Why generators, not autoloading
Rails auto-loads constants with Zeitwerk, which relies on Ruby's const_missing
hook and a deterministic file↔constant convention. Node ESM has neither: an
unqualified identifier is a ReferenceError (there is nothing to intercept), and
import requires an explicit specifier. JSails therefore expresses the same ease
as convention — a single base class, a one-line import, declarative class methods,
and generators that write the glue. The result is that you never hand-write
boilerplate, even though the mechanism is scaffolding rather than magic.