Search the docs
1.1.0GitHub
Getting StartedYour First App

Getting Started

View as Markdown

Your First App

On this page

Introduction

This guide builds a small notes app on the starter kit, from pnpm create marmeon to a page where people write, pin and delete their own notes. Along the way you meet the parts of a Marmeon app: a module, a table, a repository, routes, controllers, pages, a policy and tests.

One command, marmeon make:resource Note, writes the module: the table, a repository, a policy, the routes, the controllers and their requests, two pages and a test. The guide starts from what it writes and changes it into a notes app. It adds a body to the note, a way to pin a note and the texts in English and German, and for each change it shows the files and the lines that change, in full, so you can copy them into place. Where the generated file stays as it is, the guide says so and names what the file does. The recipe for a resource shows the generated module on its own. The same files make up the notes example in the framework's repository, and the framework's own checks build an app from them and run its tests, so the code on this page runs.

modules/notes/
├── index.ts
├── routes.ts
├── NoteRepository.ts
├── noteFields.ts
├── notes.test.ts
├── migrations/
├── controllers/
├── policies/
├── views/
└── lang/

You need Node.js 26.10 or newer and pnpm, as the installation page lists. The commands use pnpm. With npm, run npx marmeon … for the pnpm marmeon … commands and npm run … for the scripts.

Create the app

pnpm create marmeon my-app
cd my-app

With npm, the creator reads npm create marmeon@latest my-app -- --npm. The creator copies the starter kit, installs it and makes a first commit. pnpm dev starts the app on http://localhost:3000. Register an account there and confirm its address as the installation page describes. The notes app needs a signed-in user, because every note belongs to one.

A look around

Three folders matter for this guide:

PathWhat it holds
modules/The app's features. The starter kit brings auth (accounts, the app's layout), home (the page at /) and system. Your notes become a fourth module.
bootstrap/The files that connect the parts. app.ts lists the modules. routes.ts and database.d.ts are generated: the names of the routes and the types of the tables.
styles/app.css, the starter kit's tokens and the classes its pages use.

A module is a folder with an index.ts that says what the module contributes: routes, migrations, providers, commands. The rest of its files are found by their place. A route names its controller, a page is the file under views/ that a controller names, and a test is any modules/**/*.test.ts. The modules page lists the keys of index.ts, and directory structure walks through each folder.

The pages of the starter kit use a small set of classes from styles/app.css. The notes pages use the same classes and no styles of their own, so they look like the rest of the app. The look lists the classes.

A module for notes

A note is a record that belongs to the signed-in user, and the framework has a generator for that:

pnpm marmeon make:resource Note

The command writes modules/notes/ and adds the module last in modules of bootstrap/app.ts. It stops before it writes anything when modules/notes exists or when the app lacks the auth module. See adding a resource. It writes these files:

FilePurpose
modules/notes/index.tsThe module: its name, its migrations and its routes.
modules/notes/routes.tsThe routes behind authenticate() and verified(); the three that take a note from the path bind it.
modules/notes/NoteRepository.tsThe queries; pageOf() reads one page of a user's notes.
modules/notes/migrations/<timestamp>_create_notes_table.tsThe table: a UUIDv7 key, user_id (deleted with the user), title and the timestamps.
modules/notes/policies/NotePolicy.tsview, update and delete: only the owner, everyone else a 404.
modules/notes/controllers/ListNotes, StoreNote, EditNote, UpdateNote and DeleteNote with their requests, and CreateNote, which needs none.
modules/notes/views/ListNotes.tsx, views/Form.tsxThe list page and the form page, for a new note and for an edit.
modules/notes/notes.test.tsThe tests of the module, with the stranger's note on every route.

The command ends with a list of commands to run: migrate, db:types and route:types. Do not run them yet. The table needs a body and a flag for the pin first, and a migration that has run never changes. This guide runs them under Run it, when the code is complete, and until then pnpm dev and the type check may report a type that does not fit yet.

The generated module is a working notes app that has a title and nothing else. It is the starting point for what follows:

  • The table gets a body and a pinned flag.
  • The repository reads both columns and lists the pinned notes first.
  • Two more controllers pin and unpin a note, and routes.ts names them.
  • The pages and the texts show the body and the pin, in English and German.
  • The validation gets a second field, the body.
  • The policy, the list controller and the create controller stay as the generator wrote them.

The framework has no generator for repositories, and none that adds a field to a generated module, so the changes below are written by hand. Each one comes with the lines to change, or the file in full.

The table

make:resource put the current time into the migration's name, so yours is called modules/notes/migrations/<year>_<month>_<day>_<time>_create_notes_table.ts, and the block below names it with a * for the timestamp. Migrations run in the order of their names. See generating migrations.

The generated migration holds title. Add the body and the flag below it:

modules/notes/migrations/*_create_notes_table.ts
      table.string('title', 120);
      table.text('body');
      table.boolean('pinned').default(false);

table.uuidId() is the primary key, a UUIDv7 that the database makes: the id appears in the URL of a note, and the next one cannot be guessed. foreignId('user_id').constrained('users').cascadeOnDelete() points at users and removes a user's notes with the user. timestamps() adds created_at and updated_at, which the repository sorts by. Read more under columns, UUID keys and foreign keys.

The module's index.ts stays as it is. It names the module, the folder of its migrations and its routes, see the modules page.

The repository

A repository is the one class that reads and writes a table. It extends Repository, names its table, and gets the query builder and the usual operations: find, insert, update, delete. The generated pageOf() selects id and title. Replace the file so that the page also gets the body and the pin, with the pinned notes first:

modules/notes/NoteRepository.ts
import { Repository, type PageRequest, type Row } from '@marmeon/database';

export class NoteRepository extends Repository<'notes'> {
  static table = 'notes' as const;

  /** One page of a user's notes: the pinned ones first, then the most recently changed. Only what the page shows. */
  pageOf(userId: Row<'notes'>['user_id'], page: PageRequest) {
    const query = this.query()
      .select(['id', 'title', 'body', 'pinned'])
      .where('user_id', '=', userId)
      .orderBy('pinned', 'desc')
      .orderBy('updated_at', 'desc')
      .orderBy('id', 'desc');
    return this.paginate(query, page);
  }
}

pageOf() is the only query the app needs beyond those: one user's notes, as a page. The select list names the columns the page shows, so nothing else reaches the browser, and the where on user_id is why the list holds nobody else's notes. A new column is named here too, or the page never sees it. See repositories and pagination.

Routes and controllers

routes.ts has two groups, both behind authenticate(), which sends a guest to the sign-in page, and verified(), which sends an account without a confirmed address to the notice. Without verified(), an account registered with somebody else's address would reach the notes. The prefix /notes and the name prefix notes. apply to all routes in the group. See your own pages, route groups and named routes.

  • The list, the form and the store route take no note from the path: the list asks for ctx.user.id, and a new note is created for the signed-in user. They say authorize(everyone): any signed-in user.
  • The edit, update and delete routes bind :note with notes.bind('note', NoteRepository). The controller receives the row, and a path whose note does not exist answers 404. See binding a repository.

Every route says who may call it, or the app does not start. The routes that bind a note say it in their requests, with can(NotePolicy, …) (see Only your own notes). A route that binds a record and says everyone does not start. See every route decides. The recipe for a resource shows routes.ts as it is generated.

A controller is a class with a handle() method for one endpoint. The container builds it and injects what its constructor asks for. ListNotesController and CreateNoteController stay as they are. The list controller states what it accepts with defineRequest(), here the query string: pageQuery() reads ?page= and fixes ten notes to a page. ContextOf<typeof ListNotesRequest> types ctx.query, and Authenticated types ctx.user, which authenticate() has set. It asks the repository for ctx.user.id and nothing else, and returns this.view('notes/ListNotes', props): the page modules/notes/views/ListNotes.tsx with its props. See controllers.

Pinning a note

Pinning is the one thing the generator does not write. Two controllers do it, one for each direction:

pnpm marmeon make:controller PinNote --module=notes
pnpm marmeon make:controller UnpinNote --module=notes

make:controller adds Controller to the name and writes the file into controllers/. See creating a controller. Each file is a stub until the code below replaces it. routes.ts has to import both and name the two routes. Change the imports, then add the routes below the update route:

modules/notes/routes.ts
import { ListNotesController } from './controllers/ListNotesController.ts';
import { PinNoteController } from './controllers/PinNoteController.ts';
import { StoreNoteController } from './controllers/StoreNoteController.ts';
import { UnpinNoteController } from './controllers/UnpinNoteController.ts';
import { UpdateNoteController } from './controllers/UpdateNoteController.ts';
modules/notes/routes.ts
  note.put('/:note', UpdateNoteController).name('update');
  note.put('/:note/pin', PinNoteController).name('pin');
  note.delete('/:note/pin', UnpinNoteController).name('unpin');

Pinning is a PUT and unpinning a DELETE on the same path. PUT sets a state, so the second pin leaves the note as the first one did. Both routes bind :note, so both ask the policy, just like the update:

modules/notes/controllers/PinNoteController.ts
import { can } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { NoteRepository } from '../NoteRepository.ts';
import { NotePolicy } from '../policies/NotePolicy.ts';

export const PinNoteRequest = defineRequest({ authorize: can(NotePolicy, 'update', 'note') });

/** Pins a note — `PUT`, so pinning it twice is the same as once. */
export class PinNoteController extends Controller {
  static request = PinNoteRequest;

  readonly #notes: NoteRepository;

  constructor(notes: NoteRepository) {
    super();
    this.#notes = notes;
  }

  async handle(ctx: ContextOf<typeof PinNoteRequest>) {
    await this.#notes.update(ctx.params.note.id, { pinned: true });
    return this.back('/notes');
  }
}
modules/notes/controllers/UnpinNoteController.ts
import { can } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { NoteRepository } from '../NoteRepository.ts';
import { NotePolicy } from '../policies/NotePolicy.ts';

export const UnpinNoteRequest = defineRequest({ authorize: can(NotePolicy, 'update', 'note') });

/** Unpins a note. */
export class UnpinNoteController extends Controller {
  static request = UnpinNoteRequest;

  readonly #notes: NoteRepository;

  constructor(notes: NoteRepository) {
    super();
    this.#notes = notes;
  }

  async handle(ctx: ContextOf<typeof UnpinNoteRequest>) {
    await this.#notes.update(ctx.params.note.id, { pinned: false });
    return this.back('/notes');
  }
}

Each writes { pinned: … } and nothing from the request, and can(NotePolicy, 'update', 'note') sends a stranger away with a 404 before handle() runs. The test "pins a note and unpins it again" covers the two directions, and "answers 404 for somebody else's note, however it is reached" the stranger on both routes.

The pages

The generated pages show a title and word it in English in the page itself. These versions show the body and the pin, and take their texts from language files. The list page shows each note as a card, with the actions of the note below it. Replace modules/notes/views/ListNotes.tsx:

modules/notes/views/ListNotes.tsx
import type { PageProps } from '@marmeon/http';
import { Head, Link, Pagination, useTranslation } from '@marmeon/react';
import { AppLayout } from '../../auth/layouts/AppLayout.tsx';
import type { ListNotesController } from '../controllers/ListNotesController.ts';

type Props = PageProps<ListNotesController>;

/** A note's body as paragraphs: a blank line starts a new one. */
const paragraphs = (body: string) => body.split(/\n\s*\n/).filter((paragraph) => paragraph.trim() !== '');

export default function ListNotes({ notes }: Props) {
  const t = useTranslation('notes');
  return (
    <main className="app-main stack stack-loose">
      <Head title={t('index.title')} />
      <div className="cluster">
        <h1>{t('index.title')}</h1>
        <Link route="notes.create" className="button">
          {t('index.new')}
        </Link>
      </div>
      {notes.data.length === 0 && <p className="muted">{t('index.empty')}</p>}
      <div className="stack">
        {notes.data.map((note) => (
          <article key={note.id} className="card">
            <h2>
              {note.title}
              {note.pinned && (
                <>
                  {' '}
                  <span className="badge">{t('index.pinned')}</span>
                </>
              )}
            </h2>
            {paragraphs(note.body).map((paragraph, index) => (
              <p key={index}>{paragraph}</p>
            ))}
            <div className="cluster">
              <Link route="notes.edit" params={{ note: note.id }} className="button-secondary" data-size="small">
                {t('index.edit')}
              </Link>
              {note.pinned ? (
                <Link route="notes.unpin" params={{ note: note.id }} className="button-secondary" data-size="small">
                  {t('index.unpin')}
                </Link>
              ) : (
                <Link route="notes.pin" params={{ note: note.id }} className="button-secondary" data-size="small">
                  {t('index.pin')}
                </Link>
              )}
              <Link
                route="notes.destroy"
                params={{ note: note.id }}
                className="button-secondary"
                data-size="small"
                data-destructive=""
                confirm={{ title: t('index.delete_title', { name: note.title }), message: t('index.delete_confirm'), confirmLabel: t('index.delete'), destructive: true }}
              >
                {t('index.delete')}
              </Link>
            </div>
          </article>
        ))}
      </div>
      <Pagination
        meta={notes.meta}
        labels={{
          navigation: t('index.pagination'),
          previous: t('index.previous'),
          next: t('index.next'),
          page: (page) => t('index.page', { page }),
        }}
      />
    </main>
  );
}

ListNotes.layout = AppLayout;

PageProps<ListNotesController> gives the props that the controller's view() call passes. <Link route="…"> takes a route name, and the type check refuses a name that does not exist or params that the path does not need. The pin and unpin links use PUT and DELETE routes, and <Link> sends those as forms. The delete link carries confirm, which opens a dialog first. See links that send and asking the user.

The classes come from the starter kit: .card for a box, .button and .button-secondary for actions, .badge for the "Pinned" mark, .muted for quiet text, .stack and .cluster for vertical and horizontal spacing. The pages set no style attribute, which the app's content security policy would refuse. See no style attributes.

The form page serves both routes, the new note and the edit of one. Replace modules/notes/views/Form.tsx, which gets a field for the body:

modules/notes/views/Form.tsx
import type { PageProps } from '@marmeon/http';
import { Form, Head, Link, useTranslation, type FormFieldsOf, type FormRenderState } from '@marmeon/react';
import { AppLayout } from '../../auth/layouts/AppLayout.tsx';
import type { CreateNoteController } from '../controllers/CreateNoteController.ts';
import type { EditNoteController } from '../controllers/EditNoteController.ts';

type Props = PageProps<CreateNoteController> | PageProps<EditNoteController>;

export default function NoteForm({ note }: Props) {
  const t = useTranslation('notes');
  const title = note ? t('form.edit_title') : t('form.new_title');
  // The same fields for both routes: `notes.update` takes what `notes.store` takes.
  const fields = (form: FormRenderState<FormFieldsOf<'notes.store'>>) => (
    <>
      <div className="field">
        <label htmlFor="title">{t('form.title')}</label>
        <input id="title" defaultValue={note?.title} maxLength={120} {...form.field('title')} />
        {form.error('title') && <small role="alert">{form.error('title')}</small>}
      </div>
      <div className="field">
        <label htmlFor="body">{t('form.body')}</label>
        <textarea id="body" rows={8} defaultValue={note?.body} maxLength={10_000} {...form.field('body')} />
        {form.error('body') && <small role="alert">{form.error('body')}</small>}
      </div>
      <div className="cluster">
        <button type="submit" className="button" disabled={form.processing}>
          {t('form.save')}
        </button>
        <Link route="notes.index" className="button-secondary">
          {t('form.cancel')}
        </Link>
      </div>
    </>
  );

  return (
    <main className="app-main stack stack-loose">
      <Head title={title} />
      <h1>{title}</h1>
      <section className="card">
        {note ? (
          <Form route="notes.update" params={{ note: note.id }} className="stack" guard={t('form.unsaved')}>
            {fields}
          </Form>
        ) : (
          <Form route="notes.store" className="stack" guard={t('form.unsaved')}>
            {fields}
          </Form>
        )}
      </section>
    </main>
  );
}

NoteForm.layout = AppLayout;

<Form route="notes.store"> posts to a route by its name and takes its field names from that route's validation, so form.field('title') and form.error('title') are typed. .field groups a label, an input and the error under it. The guard text asks before the user leaves with unsaved changes. See forms.

The texts of both pages live in language files next to the module, in English and German:

modules/notes/lang/en.ts
/** The texts of the notes — the source language. `lang/de.ts` is checked against this file. */
const en = {
  nav: 'Notes',
  index: {
    title: 'Your notes',
    new: 'New note',
    empty: 'You have no notes yet.',
    pinned: 'Pinned',
    pin: 'Pin',
    unpin: 'Unpin',
    edit: 'Edit',
    delete: 'Delete',
    delete_title: 'Delete {name}?',
    delete_confirm: 'The note is gone for good.',
    pagination: 'Pages of notes',
    previous: 'Previous',
    next: 'Next',
    page: 'Page {page}',
  },
  form: {
    new_title: 'New note',
    edit_title: 'Edit note',
    title: 'Title',
    body: 'Note',
    save: 'Save',
    cancel: 'Back to your notes',
    unsaved: 'You have unsaved changes. Leave this page?',
  },
  flash: {
    created: 'Note saved.',
    updated: 'Note updated.',
    deleted: 'Note deleted.',
  },
} as const;

export default en;

declare module '@marmeon/i18n/messages' {
  interface Translations {
    notes: typeof en;
  }
}
modules/notes/lang/de.ts
import type { Messages } from '@marmeon/i18n/messages';
import type en from './en.ts';

/** Die Texte der Notizen auf Deutsch — ein fehlender Schlüssel oder Platzhalter ist ein Compilerfehler. */
export default {
  nav: 'Notizen',
  index: {
    title: 'Deine Notizen',
    new: 'Neue Notiz',
    empty: 'Du hast noch keine Notizen.',
    pinned: 'Angeheftet',
    pin: 'Anheften',
    unpin: 'Lösen',
    edit: 'Bearbeiten',
    delete: 'Löschen',
    delete_title: '{name} löschen?',
    delete_confirm: 'Die Notiz ist danach endgültig weg.',
    pagination: 'Seiten der Notizen',
    previous: 'Zurück',
    next: 'Weiter',
    page: 'Seite {page}',
  },
  form: {
    new_title: 'Neue Notiz',
    edit_title: 'Notiz bearbeiten',
    title: 'Titel',
    body: 'Notiz',
    save: 'Speichern',
    cancel: 'Zurück zu deinen Notizen',
    unsaved: 'Es gibt ungespeicherte Änderungen. Seite verlassen?',
  },
  flash: {
    created: 'Notiz gespeichert.',
    updated: 'Notiz aktualisiert.',
    deleted: 'Notiz gelöscht.',
  },
} satisfies Messages<typeof en>;

useTranslation('notes') reads the keys of lang/en.ts, and the declare module block types every one of them. A key missing from de.ts fails the type check. See localization.

The navigation lives in the auth module's layout, so the link to the notes is an edit of an existing file, in two places. First, load the notes' texts in modules/auth/layouts/AppLayout.tsx, below the line that reads const user = …:

modules/auth/layouts/AppLayout.tsx
  const user = auth?.user ?? null;
  const notes = useTranslation('notes');

Then add the link below the dashboard link in the navigation:

modules/auth/layouts/AppLayout.tsx
            <Link href={home ?? '/'}>{t('layout.dashboard')}</Link>
            <Link route="notes.index" activeMatch="notes.*">
              {notes('nav')}
            </Link>

activeMatch="notes.*" marks the link as current on every page whose route name starts with notes..

Validation

A form sends two fields, and a note without a title is not a note. The generated store and update controllers each hold a schema with the title. The body is a second field, so the schema moves into a file of its own, which both controllers share. Create it:

modules/notes/noteFields.ts
import { rules as r } from '@marmeon/validation';

/** What a note's form sends: a title, and a body that may stay empty. Nothing else — the owner is the signed-in user. */
export const noteFields = r.object({
  title: r.string().trim().required().max(120),
  body: r.string().max(10_000),
});

title is trimmed, required and at most 120 characters, which is also the width of the column. body may be empty. A field the schema does not name never reaches the controller, so a form that sends a user_id changes nothing. See validation.

The store controller takes the schema from there. It also words the toast through the translator, in the language files above, instead of an English string:

modules/notes/controllers/StoreNoteController.ts
import type { Authenticated } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { Translator } from '@marmeon/i18n';
import { NoteRepository } from '../NoteRepository.ts';
import { noteFields } from '../noteFields.ts';

export const StoreNoteRequest = defineRequest({ schema: noteFields });

/** Saves a new note for the signed-in user. */
export class StoreNoteController extends Controller {
  static request = StoreNoteRequest;

  readonly #notes: NoteRepository;
  readonly #translator: Translator;

  constructor(notes: NoteRepository, translator: Translator) {
    super();
    this.#notes = notes;
    this.#translator = translator;
  }

  async handle(ctx: ContextOf<typeof StoreNoteRequest> & Authenticated) {
    await this.#notes.insert({ user_id: ctx.user.id, title: ctx.body.title, body: ctx.body.body });
    return this.redirect().route('notes.index').flash('toast', { kind: 'success', message: this.#translator.get('notes.flash.created') });
  }
}

schema in defineRequest() validates the body before handle() runs. When it fails, the user returns to the form with the errors and the input, and the controller does not run. handle() reads ctx.body, typed from the schema, and the owner of the new note is ctx.user, never a field of the form. The redirect carries a flash message, which the layout shows as a toast. See validated input and flash messages.

Only your own notes

Route binding finds a note by its id, whoever owns it. A policy decides whether the signed-in user may touch it, and make:resource wrote it: modules/notes/policies/NotePolicy.ts has one method for each ability, view, update and delete, and each lets the owner in and answers everyone else with denyAsNotFound(). That is a 404, the answer for a note that does not exist, instead of a 403, so nobody can probe which ids exist. The file stays as it is. The recipe for a resource shows it, and writing policies explains the rest.

A controller asks the policy through its request: authorize: can(NotePolicy, 'update', 'note') runs the update method with the user and the bound note parameter. The check runs before the validation, so a stranger who sends an invalid form gets the 404 and no error messages about fields. See the order of a request. The generated edit, update and delete controllers do this already. Three things in them change: the form gets the body, the update writes it, and the toasts come from the language files.

modules/notes/controllers/EditNoteController.ts
import { can } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { NotePolicy } from '../policies/NotePolicy.ts';

export const EditNoteRequest = defineRequest({ authorize: can(NotePolicy, 'update', 'note') });

/** The form of one of the user's notes — somebody else's note answers 404 before this runs. */
export class EditNoteController extends Controller {
  static request = EditNoteRequest;

  handle(ctx: ContextOf<typeof EditNoteRequest>) {
    const { id, title, body } = ctx.params.note;
    return this.view('notes/Form', { note: { id, title, body } });
  }
}

ctx.params.note is the row that the binding found. The controller takes the three columns the form needs out of it, so the page gets no others. This is the place where a new column reaches the form. See pages.

modules/notes/controllers/UpdateNoteController.ts
import { can } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { Translator } from '@marmeon/i18n';
import { NoteRepository } from '../NoteRepository.ts';
import { noteFields } from '../noteFields.ts';
import { NotePolicy } from '../policies/NotePolicy.ts';

export const UpdateNoteRequest = defineRequest({ schema: noteFields, authorize: can(NotePolicy, 'update', 'note') });

/** Saves the changes to one of the user's notes. */
export class UpdateNoteController extends Controller {
  static request = UpdateNoteRequest;

  readonly #notes: NoteRepository;
  readonly #translator: Translator;

  constructor(notes: NoteRepository, translator: Translator) {
    super();
    this.#notes = notes;
    this.#translator = translator;
  }

  async handle(ctx: ContextOf<typeof UpdateNoteRequest>) {
    await this.#notes.update(ctx.params.note.id, { title: ctx.body.title, body: ctx.body.body });
    return this.redirect().route('notes.index').flash('toast', { kind: 'success', message: this.#translator.get('notes.flash.updated') });
  }
}

The update writes an object it builds field by field, { title, body }, and never ctx.body as it arrived. A field that is not named there never reaches the table, which protects against mass assignment.

modules/notes/controllers/DeleteNoteController.ts
import { can } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { Translator } from '@marmeon/i18n';
import { NoteRepository } from '../NoteRepository.ts';
import { NotePolicy } from '../policies/NotePolicy.ts';

export const DeleteNoteRequest = defineRequest({ authorize: can(NotePolicy, 'delete', 'note') });

/** Deletes one of the user's notes. */
export class DeleteNoteController extends Controller {
  static request = DeleteNoteRequest;

  readonly #notes: NoteRepository;
  readonly #translator: Translator;

  constructor(notes: NoteRepository, translator: Translator) {
    super();
    this.#notes = notes;
    this.#translator = translator;
  }

  async handle(ctx: ContextOf<typeof DeleteNoteRequest>) {
    await this.#notes.delete(ctx.params.note.id);
    return this.redirect().route('notes.index').flash('toast', { kind: 'success', message: this.#translator.get('notes.flash.deleted') });
  }
}

Only the toast changes here, so that the deletion is worded in the language files as well.

Adding a field

The body is the field this guide adds, and a field of your own goes through the same places. Add a column in a second migration (pnpm marmeon make:migration add_url_to_notes_table --module=notes) or, as here, in the first one while it has not run. Then add the field in every place that names fields one by one:

PlaceWhat changes for the field
The new migration, or *_create_notes_table.ts while it has not runThe column.
noteFields.tsThe rule of the field, in the schema that StoreNoteController and UpdateNoteController share. Keep user_id out of it.
StoreNoteController.tsThe field in the object that insert() gets.
UpdateNoteController.tsThe field in the object that update() gets.
EditNoteController.tsThe field in the props it hands to the form, which are { id, title, body } and no more.
views/Form.tsxThe input.
NoteRepository.tsThe field in the select of pageOf(), if the list shows it.
views/ListNotes.tsxWhere the list shows it.
lang/en.ts, lang/de.tsThe label and the texts around it.
notes.test.tsThe field in "changes a note", and its rule in "refuses a note without a title, with a title that is too long or with a body that is".

Writing the fields one by one is what protects against mass assignment: do not replace those objects with ctx.body, and do not put user_id into a schema. The tests below hold each guard: "keeps a note for the signed-in user whatever the form says" for the owner, "lists only the notes of the signed-in user, the pinned ones first" for the list, "refuses a note without a title, with a title that is too long or with a body that is" for the rules, and "answers 404 for somebody else's note, however it is reached" for every route that takes a note from the path.

Run it

The module is complete, and the three commands that make:resource listed can run now that the migration holds its columns:

pnpm marmeon migrate
pnpm marmeon db:types
pnpm marmeon route:types

migrate runs the migration and shows its name. db:types writes the types of the tables into bootstrap/database.d.ts, which is where Row<'notes'> comes from; in development migrate writes the file as well. route:types writes the names of the routes into bootstrap/routes.ts, which is how <Link route="notes.index"> becomes checked. pnpm dev keeps that file up to date while it runs, so you need the command only without it, as in a build script. See running migrations and typed route names.

With pnpm dev running, sign in and open http://localhost:3000/notes.

Tests

The tests of a module sit next to it. Each one boots the app on a fresh database, acts as a user and sends requests through the real middleware, controllers and pages. make:resource wrote modules/notes/notes.test.ts as well, with the stranger's note on every route. Replace it with this one, which also covers the body, the pages of the list and the pin:

modules/notes/notes.test.ts
import { createTestApp, type TestApp } from '@marmeon/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import application from '../../bootstrap/app.ts';
import { UserFactory, UserRepository } from '../auth/index.ts';
import type { ListNotesController } from './controllers/ListNotesController.ts';
import { NoteRepository } from './NoteRepository.ts';

// Who may see and change which note.

let app: TestApp;
// Every test boots the app on a refreshed database — seconds on a busy machine, so its hook and tests get 30 s.
beforeEach(async () => {
  app = await createTestApp(application, { database: 'refresh' });
}, 30_000);

const notes = () => app.make(NoteRepository);

describe('the notes', { timeout: 30_000 }, () => {
  it('sends a guest to the sign-in page', async () => {
    await app.get('/notes').assertRedirect('/login');
    await app.get('/notes/create').assertRedirect('/login');
    await app.post('/notes', { form: { title: 'Hello', body: '' } }).assertRedirect('/login');
  });

  it('lists only the notes of the signed-in user, the pinned ones first', async () => {
    const ada = await app.factory(UserFactory).create();
    const bob = await app.factory(UserFactory).create();
    await notes().insert({ user_id: ada.id, title: 'Groceries', body: 'Milk' });
    await notes().insert({ user_id: ada.id, title: 'Ideas', body: 'Many', pinned: true });
    await notes().insert({ user_id: bob.id, title: 'Secret', body: 'Not for Ada' });

    await app.actingAs(ada).navigating().get('/notes').assertPage<ListNotesController>('notes/ListNotes', (props) => {
      expect(props.notes.data.map((note) => note.title)).toEqual(['Ideas', 'Groceries']);
      expect(props.notes.meta.total).toBe(2);
    });
  });

  it('pages the list', async () => {
    const ada = await app.factory(UserFactory).create();
    for (let number = 1; number <= 12; number++) await notes().insert({ user_id: ada.id, title: `Note ${number}`, body: '' });
    await app.actingAs(ada).navigating().get('/notes?page=2').assertPage<ListNotesController>('notes/ListNotes', (props) => {
      expect(props.notes.data).toHaveLength(2);
      expect(props.notes.meta.lastPage).toBe(2);
    });
  });

  it('creates a note for the signed-in user', async () => {
    const ada = await app.factory(UserFactory).create();
    await app.actingAs(ada).navigating().get('/notes/create').assertPage('notes/Form', { note: null });
    await app.actingAs(ada).post('/notes', { form: { title: '  Groceries  ', body: 'Milk' } }).assertRedirect('/notes');
    expect(await notes().firstWhere({ user_id: ada.id })).toMatchObject({ title: 'Groceries', body: 'Milk', pinned: false });
  });

  it('keeps a note for the signed-in user whatever the form says', async () => {
    const ada = await app.factory(UserFactory).create();
    const bob = await app.factory(UserFactory).create();
    await app.actingAs(ada).post('/notes', { form: { title: 'Mine', body: '', user_id: String(bob.id), pinned: 'true' } }).assertRedirect('/notes');
    const mine = await notes().findBy('title', 'Mine');
    expect(mine).toMatchObject({ user_id: ada.id, pinned: false });
    await app.actingAs(ada).put(`/notes/${mine!.id}`, { form: { title: 'Still mine', body: '', user_id: String(bob.id), pinned: 'true' } }).assertRedirect('/notes');
    expect(await notes().find(mine!.id)).toMatchObject({ title: 'Still mine', user_id: ada.id, pinned: false });
  });

  it('refuses a note without a title, with a title that is too long or with a body that is', async () => {
    const ada = await app.factory(UserFactory).create();
    await app.actingAs(ada).post('/notes', { form: { title: ' ', body: 'x' } }).assertRedirect().assertInvalid(['title']);
    await app.actingAs(ada).post('/notes', { form: { title: 'x'.repeat(121), body: '' } }).assertRedirect().assertInvalid(['title']);
    await app.actingAs(ada).post('/notes', { form: { title: 'Long', body: 'x'.repeat(10_001) } }).assertRedirect().assertInvalid(['body']);
    expect(await notes().count()).toBe(0);
  });

  it('changes a note', async () => {
    const ada = await app.factory(UserFactory).create();
    const note = await notes().insert({ user_id: ada.id, title: 'Draft', body: '' });
    await app.actingAs(ada).navigating().get(`/notes/${note.id}/edit`).assertPage('notes/Form', { note: { id: note.id, title: 'Draft', body: '' } });
    await app.actingAs(ada).put(`/notes/${note.id}`, { form: { title: 'Final', body: 'Done' } }).assertRedirect('/notes');
    expect(await notes().find(note.id)).toMatchObject({ title: 'Final', body: 'Done' });
    await app.actingAs(ada).put(`/notes/${note.id}`, { form: { title: '', body: '' } }).assertRedirect().assertInvalid(['title']);
  });

  it('pins a note and unpins it again', async () => {
    const ada = await app.factory(UserFactory).create();
    const note = await notes().insert({ user_id: ada.id, title: 'Important', body: '' });
    await app.actingAs(ada).put(`/notes/${note.id}/pin`).assertRedirect();
    await app.actingAs(ada).put(`/notes/${note.id}/pin`).assertRedirect();
    expect((await notes().find(note.id))!.pinned).toBe(true);
    await app.actingAs(ada).delete(`/notes/${note.id}/pin`).assertRedirect();
    expect((await notes().find(note.id))!.pinned).toBe(false);
  });

  it('deletes a note', async () => {
    const ada = await app.factory(UserFactory).create();
    const note = await notes().insert({ user_id: ada.id, title: 'Obsolete', body: '' });
    await app.actingAs(ada).delete(`/notes/${note.id}`).assertRedirect('/notes');
    expect(await notes().find(note.id)).toBeUndefined();
  });

  it("answers 404 for somebody else's note, however it is reached", async () => {
    const ada = await app.factory(UserFactory).create();
    const bob = await app.factory(UserFactory).create();
    const note = await notes().insert({ user_id: bob.id, title: 'Secret', body: 'Not for Ada' });
    const ada404 = app.actingAs(ada);
    await ada404.get(`/notes/${note.id}/edit`).assertNotFound();
    await ada404.put(`/notes/${note.id}`, { form: { title: 'Mine now', body: '' } }).assertNotFound();
    await ada404.put(`/notes/${note.id}/pin`).assertNotFound();
    await ada404.delete(`/notes/${note.id}/pin`).assertNotFound();
    await ada404.delete(`/notes/${note.id}`).assertNotFound();
    await ada404.get('/notes/no-such-id/edit').assertNotFound();
    expect(await notes().find(note.id)).toMatchObject({ title: 'Secret', pinned: false });
  });

  it('deletes the notes with their user', async () => {
    const ada = await app.factory(UserFactory).create();
    await notes().insert({ user_id: ada.id, title: 'Gone with Ada', body: '' });
    await app.make(UserRepository).deleteAccounts([ada.id]);
    expect(await notes().count()).toBe(0);
  });
});

app.actingAs(user) signs a user in for the requests that follow. assertRedirect, assertPage, assertInvalid and assertNotFound check the response. The test "answers 404 for somebody else's note, however it is reached" matters most: a note of another user answers 404 on every route, and its row stays as it was. See testing and HTTP tests.

pnpm test
pnpm lint

Build and start

pnpm build
NODE_ENV=production pnpm marmeon start

build checks the types, including the route names in your pages, and bundles the browser's files. start serves the build. It runs in production, and refuses a NODE_ENV=development that comes from .env alone: the .env of the starter kit says development, so set NODE_ENV=production in the shell to try the build on your machine, and a server sets its own NODE_ENV. See building and starting.

Where to go next