Getting Started
View as MarkdownYour 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-appWith 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:
| Path | What 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 NoteThe 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:
| File | Purpose |
|---|---|
modules/notes/index.ts | The module: its name, its migrations and its routes. |
modules/notes/routes.ts | The routes behind authenticate() and verified(); the three that take a note from the path bind it. |
modules/notes/NoteRepository.ts | The queries; pageOf() reads one page of a user's notes. |
modules/notes/migrations/<timestamp>_create_notes_table.ts | The table: a UUIDv7 key, user_id (deleted with the user), title and the timestamps. |
modules/notes/policies/NotePolicy.ts | view, 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.tsx | The list page and the form page, for a new note and for an edit. |
modules/notes/notes.test.ts | The 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
pinnedflag. - The repository reads both columns and lists the pinned notes first.
- Two more controllers pin and unpin a note, and
routes.tsnames 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:
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:
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 sayauthorize(everyone): any signed-in user. - The edit, update and delete routes bind
:notewithnotes.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=notesmake: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:
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'; 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:
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');
}
}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:
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:
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:
/** 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;
}
}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 = …:
const user = auth?.user ?? null;
const notes = useTranslation('notes');Then add the link below the dashboard link in the navigation:
<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:
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:
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.
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.
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.
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:
| Place | What changes for the field |
|---|---|
The new migration, or *_create_notes_table.ts while it has not run | The column. |
noteFields.ts | The rule of the field, in the schema that StoreNoteController and UpdateNoteController share. Keep user_id out of it. |
StoreNoteController.ts | The field in the object that insert() gets. |
UpdateNoteController.ts | The field in the object that update() gets. |
EditNoteController.ts | The field in the props it hands to the form, which are { id, title, body } and no more. |
views/Form.tsx | The input. |
NoteRepository.ts | The field in the select of pageOf(), if the list shows it. |
views/ListNotes.tsx | Where the list shows it. |
lang/en.ts, lang/de.ts | The label and the texts around it. |
notes.test.ts | The 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:typesmigrate 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:
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 lintBuild and start
pnpm build
NODE_ENV=production pnpm marmeon startbuild 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
- Authentication and authorization go further than the starter kit's sign-in.
- Two-factor authentication and passkeys add a second factor.
- UUID keys explain the key
make:resourcechose, and the integer key with a public UUID. - Queries covers relations, filters and transactions beyond a single table.
- Pagination shows cursors, searching and sorting for longer lists.
- Forms covers live validation, uploads and remembered input.
- Localization adds a language.
- Deployment takes the app to a server.