Master Guides

Getting Started

Build and run your first full-stack Master app in a few minutes.

This guide takes you from an empty folder to a running full-stack application: a Next.js frontend, a MasterController API, and a MasterRecord database — all wired together.

1. Install the CLI#

terminal
$ npm install -g master

Master requires Node.js 22.12 or newer. Verify the install:

terminal
$ master --version
$ master info

2. Create a new app#

terminal
$ master new my-app

This scaffolds the monorepo, installs dependencies, and initializes git. Choose your database with --db (defaults to SQLite — zero setup):

terminal
$ master new my-app --db postgres
$ master new my-app --db mysql
$ master new blog --skip-frontend # API only

3. Set up the database#

terminal
$ cd my-app
$ master db migrate

New apps ship an InitialCreate migration for the example User model (the way ASP.NET project templates ship theirs), so this applies it straight away. For SQLite this creates backend/db/appcontext.sqlite instantly (and a separate db/test/ database is used by master test). For MySQL/Postgres, edit the credentials in backend/config/environments/env.development.json first (keyed by the context name, AppContext):

env.development.json
{
  "AppContext": {
    "type": "postgres",
    "host": "127.0.0.1",
    "port": 5432,
    "database": "my_app_development",
    "user": "postgres",
    "password": ""
  }
}

4. Run it#

terminal
$ master dev

Both restart on change, and each process’s output is prefixed ([backend], [frontend]). The homepage calls the backend’s /health endpoint to prove the full stack is connected.

5. Build a feature#

Scaffold a complete resource — a model, a validated RESTful API controller, typed routes, and a Next.js page — with one command, then generate and apply its migration:

terminal
$ master generate scaffold post title:string body:text published:bool
$ master db new AddPosts
$ master db migrate

You now have a working /posts JSON API and a /posts page. Try it:

terminal
curl -X POST http://localhost:3001/posts \
  -H 'Content-Type: application/json' \
  -d '{"title":"Hello","body":"By the power of Grayskull","published":true}'
#   201 Created + Location: /posts/1

curl -X POST http://localhost:3001/posts -H 'Content-Type: application/json' -d '{}'
#   400 ValidationProblemDetails: {"errors":{"title":["title is required"], ...}}

curl http://localhost:3001/posts/abc
#   404 — the route is /posts/:id(int), so non-integers never reach the controller

curl http://localhost:3001/posts

The generated controller shows the whole ASP.NET-style toolkit in about forty lines:

backend/app/controllers/postsController.js (generated)
// PostsController — RESTful JSON API backed by MasterRecord.
// this.db is a request-scoped AppContext (ASP.NET/EF: scoped DbContext) — registered in server.js.
// Input is bound + validated from `static schemas` into this.model (invalid -> 400 ValidationProblemDetails).
export default class PostsController {
  static schemas = {
    create: { body: { title: { type: 'string', required: true }, body: { type: 'string', required: true }, published: { type: 'boolean' } } },
    update: { body: { title: { type: 'string' }, body: { type: 'string' }, published: { type: 'boolean' } } },
  };

  // Protect writes once auth is configured in server.js (ASP.NET [Authorize]):
  // static authorize = { create: true, update: true, destroy: true };

  constructor(requestObject) {
    this.requestObject = requestObject;
  }

  // GET /posts
  async index() {
    const data = await this.db.Post.toList();
    this.ok({ data });
  }

  // GET /posts/:id
  async show(obj) {
    const item = await this.db.Post.find(obj.params.id);
    if (!item) return this.notFound('Post not found');
    this.ok({ data: item });
  }

  // POST /posts
  async create() {
    const item = this.db.Post.new();
    Object.assign(item, this.model);
    await this.db.saveChanges();
    this.created(`/posts/${item.id}`, { data: item });
  }

  // PUT /posts/:id
  async update(obj) {
    const item = await this.db.Post.find(obj.params.id);
    if (!item) return this.notFound('Post not found');
    Object.assign(item, this.model);
    await this.db.saveChanges();
    this.ok({ data: item });
  }

  // DELETE /posts/:id
  async destroy(obj) {
    const item = await this.db.Post.find(obj.params.id);
    if (!item) return this.notFound('Post not found');
    this.db.Post.remove(item);
    await this.db.saveChanges();
    this.noContent();
  }
}

Open http://localhost:3001/docs — the new endpoints, their parameters and request schemas are already in the Swagger UI.

6. Configuration, secrets and auth#

The backend reads configuration ASP.NET-style — config/appsettings.json → appsettings.<env>.json → env.<env>.json → user secrets → environment variables (Section__Key). Keep secrets out of the repo:

terminal
$ master secrets set Auth:JwtSecret "$(openssl rand -hex 32)" # stored in ~/.master, not in git
$ master secrets list # masked; --reveal to show

Once Auth:JwtSecret is present, server.js registers a JWT bearer scheme and an AdminOnly policy. Uncomment static authorize in the controller (or add { authorize: true } to a route) and writes require a signed-in user. In production set Auth__JwtSecret as an environment variable. See Secrets and Configuration.

7. Test#

terminal
$ master test

Runs backend/test/*.test.js with Node’s built-in runner under NODE_ENV=test. The scaffolded health.test.js boots the real pipeline in-process with master.createTestClient() — no ports, no mocks. See Testing.

How the pieces connect
server.js boots with setupServer() → addScoped('db', AppContext) → middleware → startMVC() → start(). Controllers get this.db (a fresh context per request), this.model (validated input), this.user, this.logger, and reply with this.ok / created / noContent / notFound / problem. Models register in AppContext.js via this.dbset(Entity). The frontend calls the API through frontend/app/lib/api.ts.

What’s next?#