Skip to content

Development

github-actions[bot] edited this page Oct 12, 2026 · 13 revisions

Development

Marmot is one TypeScript codebase (Payload CMS 3 + Next.js, pnpm) that runs as three processes. This page gets you from a clone to a running stack and through the test, migration and build workflows. Project conventions live in CONTRIBUTING.md and .claude/skills/marmot-conventions/SKILL.md.

Prerequisites

  • Node.js 22 and pnpm 10 (corepack enable installs the pinned version).
  • Postgres 16 and Redis 7, either through Docker or as local binaries. MongoDB 7 optionally.
  • For Playwright: a Chromium (pnpm exec playwright install chromium, or set PLAYWRIGHT_CHROMIUM_PATH).

Setup

pnpm install
cp .env.example .env            # defaults point at local Postgres + Redis
pnpm services:up                # Postgres + Redis (+ Mongo with Docker)
pnpm dev                        # web :3000 · worker · realtime :3001

Set PAYLOAD_SECRET in .env to any string of 16+ characters. pnpm services:up runs scripts/dev-services.sh: with a Docker daemon it starts docker/docker-compose.dev.yml (Postgres, Redis, MongoDB on their default ports); without one it starts local redis-server and a Postgres 16 cluster via pg_ctlcluster and creates the marmot role (password marmot) with the marmot and marmot_test databases. pnpm services:down stops them.

Without Docker

Install Postgres and Redis from your package manager, then either let pnpm services:up manage them or run them yourself and point DATABASE_URL / REDIS_URL at them. For a zero-dependency database use SQLite:

DATABASE_ADAPTER=sqlite DATABASE_URL=file:./data/marmot.db pnpm dev:web

SQLite has no migrations (the schema is pushed in development) and is not supported in production; Redis is still required by the worker and realtime processes.

The three processes

Command Process Notes
pnpm dev:web Next.js dev server with Payload (UI, /admin, REST, GraphQL) Pushes schema changes to the dev database on the fly (Postgres/SQLite in development).
pnpm dev:worker src/worker.ts through scripts/run-ts.mjs BullMQ job schedulers, checks, stats, notifications. Needs Redis.
pnpm dev:realtime src/realtime.ts through scripts/run-ts.mjs socket.io on REALTIME_PORT. The Next dev server does not proxy /socket.io, so set NEXT_PUBLIC_REALTIME_URL=http://localhost:3001 in .env for live updates in development (the realtime server allows NEXT_PUBLIC_SERVER_URL in CORS).
pnpm dev all three with concurrently Coloured, prefixed logs.

scripts/run-ts.mjs is the TypeScript loader for the long-running processes. Do not run them with payload run or bare tsx — both exit before the schedulers are up. In production the same entrypoints are bundled by pnpm build:server (below).

Once web is up, open http://localhost:3000: the setup wizard creates your superadmin and first organization. /admin is the Payload admin panel (superadmins only).

Everyday commands

Command What it does
pnpm check lint + typecheck + test:int. Run before every push.
pnpm lint / pnpm format ESLint / Prettier check (pnpm format:fix writes).
pnpm typecheck tsc --noEmit over app, server and tests.
pnpm generate:types Regenerate src/payload-types.ts after changing a collection or global. Never edit that file by hand.
pnpm generate:importmap Regenerate the admin import map after adding admin components.
pnpm build Production Next.js build (also typechecks test files against DATABASE_ADAPTER).
pnpm build:server esbuild bundles of worker, realtime and migrate to dist/server/*.mjs (what the Docker image runs).
pnpm build:action Rebuild the committed GitHub Action bundle action/dist/index.mjs (GitHub Action).

Tests

Integration tests (Vitest)

tests/int/*.int.spec.ts and src/**/*.test.ts run with pnpm test:int against a real database (vitest.config.mts, vitest.setup.ts). They boot Payload with the collection access rules on, so they cover what can break silently: RBAC, the engine state machine, stats rollups, provider payloads, route handlers. Files run serially (fileParallelism: false) because they share one database.

  • The database comes from DATABASE_URL in your environment or .env. Use a dedicated database, not the one your dev server uses: in development Vitest pushes the schema into it.

  • If a run fails on boot with a schema-push error (e.g. "column contains null values") or hangs, the database has drifted. Point DATABASE_URL at a fresh one and rerun:

    createdb -h localhost -U marmot marmot_test2
    DATABASE_URL=postgres://marmot:marmot@localhost:5432/marmot_test2 pnpm test:int

    Never answer Payload's data-loss prompt interactively in a test run.

  • Redis is not required: vitest.setup.ts sets MARMOT_DISABLE_ENGINE_HOOKS=1 so collection hooks skip the scheduler sync; engine behaviour is tested directly.

  • Run one file with pnpm test:int tests/int/members.int.spec.ts.

  • The OTLP export test also sends its checks through a real OpenTelemetry Collector when OTEL_COLLECTOR_URL is set (CI always does, and fails without it). Locally run the Collector with tests/fixtures/otel/collector.yaml (otelcol --config …, or the otel/opentelemetry-collector image with --network host) and set OTEL_COLLECTOR_URL=http://127.0.0.1:4318.

  • On MongoDB ids are strings, so fixtures must not assume numbers: cast through unknown (as unknown as Monitor) or create real documents.

End-to-end tests (Playwright)

tests/e2e/*.e2e.spec.ts cover the critical paths (setup wizard, login, monitors, members, status pages, admin). CI runs them on Postgres and MongoDB against a production build (pnpm build, pnpm migrate, E2E_WEB_COMMAND="pnpm start"). Locally:

pnpm exec playwright install chromium        # once
pnpm test:e2e                                # starts `pnpm dev:web` for you

00-setup.e2e.spec.ts needs a database without users and is skipped outside CI. Set PLAYWRIGHT_CHROMIUM_PATH to use an existing Chromium binary.

The web and worker processes send every email to a small SMTP sink (tests/e2e/mail-sink.mjs, SMTP on web port + 3, E2E_SMTP_PORT); specs read what was sent from its HTTP API (GET <mailURL>/messages?to=…, web port + 4, E2E_MAIL_PORT), for example the sign-in link in magic-link.e2e.spec.ts.

What to test where

Integration tests for behaviour that matters (access control, state machines, rollups, provider payloads, route handlers). E2E only for critical user paths. No snapshot tests, no tests of trivial getters.

Localisation

User-facing text goes through next-intl in "without i18n routing" mode: URLs carry no locale prefix, the language comes from the signed-in user. Everything lives in src/i18n/:

File Role
locales.ts locales (every catalogue), appLocales (complete catalogues), defaultLocale, localeNames and the pure resolver.
messages/<locale>.json One catalogue per language, namespaced by area (common, auth, settings, statusPages, …). English is the reference.
messages/index.ts Static map of catalogues (no computed import(), so the worker bundle can include it).
messages/scopes.ts PUBLIC_MESSAGE_SCOPES: the keys a status page visitor can see; the only keys fr, de and es translate (merged over English).
formats.ts Named date and number formats (short, zoned, percent, …) and the default time zone.
request.ts, server.ts next-intl request config; locale of the current request (getRequestLocale, getStatusPageLocale).
resolve.ts Locale and time zone of a request or a status page from plain Headers; usable in route handlers and tests.
translator.ts getTranslator(locale) / getStaticFormatter(locale, timeZone) for code outside React (RSS, emails, notification bodies, the worker).
admin.ts Payload admin languages and the marmot: translations used for collection labels (adminT('marmot:language')).

Keys are type-checked: src/types/next-intl.d.ts derives them from en.json, so a missing or misspelt key fails pnpm typecheck, and tests/int/i18n.int.spec.ts checks that every catalogue has the same keys.

Adding a string

  1. Add the key to src/i18n/messages/en.json under the namespace of the area (statusPages.footer.rss). Use camelCase leaves, name the key after its meaning rather than its English text, and keep whole sentences in one message (translators need the full sentence to reorder it).
  2. Use it:
    • server components and generateMetadata: const t = await getTranslations('statusPages.footer'), then t('rss');
    • client islands and shared components: const t = useTranslations('statusPages.footer');
    • outside React (route handlers, worker): getTranslator(locale)('statusPages.footer.rss').
  3. Values are ICU messages: {name} placeholders, plurals "{count, plural, one {# check} other {# checks}}", selects "{status, select, up {…} other {…}}", and rich text "No account yet? <link>Create one</link>" rendered with t.rich('noAccount', { link: (chunks) => <Link …>{chunks}</Link> }).
  4. Dates and numbers never go through toLocaleString(): use useFormatter() / getFormatter() with a named format (format.dateTime(date, 'short'), format.number(fraction, 'percent')). The request config renders in UTC, public status pages in the organization's settings.timezone; passing the zone explicitly is what keeps the server HTML and the client hydration identical. Inside an organization (/[orgSlug]/…) the layout wraps the pages in OrgTimeZoneProvider, so client islands format in the organization's zone automatically; server components there take a timeZone prop and pass it as an override (format.dateTime(date, 'precise', { timeZone })). Sorting by name uses the locale too (new Intl.Collator(useLocale()), see selectMonitorList).
  5. Stable identifiers stay untranslated: monitor type slugs, status enum values, webhook payload keys, log lines and the code of API errors. Only their display labels are messages.
  6. Schemas shared by a form and a route handler take their messages from a resolver: the form builds them with its translator (createMonitorFormSchema((key, values) => t(key, values))), the API keeps the English instance (monitorFormSchema in monitor-schema.ts, a separate module so the catalogue is not bundled into the browser), so API responses stay stable.
  7. The maintenance schema takes a message map instead of a resolver (createMaintenanceFormSchema(messages)): the handlers use the English defaults, the form passes t(…) values built in a useMemo. Validators shared with the server take a message function the same way (validateOrganizationSlug(value, message) with errors.slug*).
  8. ICU syntax characters in a message are quoted with apostrophes: '{{' monitor.name '}}' prints {{ monitor.name }}, '<'token'>' prints <token> (a bare <…> is read as a rich-text tag).

Literal text lint rule

pnpm lint runs marmot/no-literal-jsx-text (eslint-rules/no-literal-jsx-text.mjs) on src/app/**/*.tsx and src/components/**/*.tsx (not components/ui, not the Payload admin). It reports JSX text with letters, string literals rendered as children ({busy ? 'Saving…' : t('save')}) and literal placeholder, title, alt, aria-label, label and description props. It lets through text without letters, text inside <code>, <pre>, <kbd> and <samp>, technical values (URLs, hostnames, paths, identifiers such as my-node, JSON and markup) and the names in its allow option (Marmot, the Aa font specimen). An example value that still trips it gets a disable comment that says why:

// eslint-disable-next-line marmot/no-literal-jsx-text -- example value, not prose
placeholder = 'read write'

It does not see strings built outside JSX (a const label = 'Save' passed down later); those are caught in review.

Server-side strings

Text the server sends on behalf of an organization (emails, notification bodies, expiry warnings) is written in the organization's settings.language, falling back to en; the recipient is often not a Marmot user. src/server/i18n.ts has the helpers:

  • getOrganizationI18n(payload, orgOrId) → { locale, timeZone } (never throws; English/UTC when the organization is missing), serverTranslator(locale) (memoised getTranslator) and organizationFormatter(i18n) for dates in the organization's time zone.
  • Notifications: sendNotification resolves the channel organization's language once and passes it to the provider as ctx.locale. Providers hand it to the shared builders (statusLabel(status, locale), renderMessageTemplate(…, locale)). The [name] [status] msg shape and formatHeartbeatTime stay as they are (Kuma's wire format); only the words come from notifications.messages.*.
  • Emails: email.* (invitation, SMTP test). HTML bodies use t.markup with escaped values.
  • API errors: throw apiError('memberNotFound', 404) (src/server/errors.ts) instead of new APIError('Member not found.', 404). It is still an APIError with the English message, so Payload REST, logs and tests see the same thing; withErrors re-renders it in the request locale (user language → cookie → Accept-Language). Return-style helpers: unauthorized(request), forbidden(request), localizedError(request, key, status). The key is the stable identifier.
  • Route helpers with their own response shape (jsonError in src/server/monitors/http.ts, notifications/api.ts, status-pages/http.ts, billing/http.ts) keep their shape and take the text from errorText(request, 'statusPageNotFound') (src/server/request-locale.ts). Their authenticate helpers call rememberRequestUser, so the user's language wins over the headers; payloadError(error, request), errorResponse(error, request) and errorMessage(error, request) re-render apiErrors thrown further down.
  • Collection hooks throw ValidationErrors with userErrorText(req, key): route handlers run the Local API as the request user, so the field message reaches the client in that user's language. Field validate functions do the same with userLocale(req.user).
  • Notification payloads: words a provider adds (embed titles, field names, card headings) come from providerText(locale)('serviceName') (notifications.messages.providers.*); timeLine(heartbeat, locale) renders Time: …. The provider form (labels, descriptions, option labels) comes from notifications.providers.<name>; describeNotificationProviders(locale) applies it, and descriptors-i18n.test.ts checks that the English catalogue matches every provider's fieldMeta.
  • Import reports: the planners take an ImportText (importText(locale), importExport.messages.*) and applyImportPlan a locale; the default is English.

Stays English on purpose: zod messages of the API schemas (monitorFormSchema and friends; the forms build translated schemas), the outbound guard's Blocked: … (MONITOR_…) diagnostics (matched by findBlockedMessage), delivery errors and heartbeat messages (stored data), the push endpoint's Kuma responses, badge SVG text, RSS dates (RFC 822) and machine endpoints with shared caches (/api/status-pages/resolve-domain).

The English catalogue reproduces the previous hard-coded text byte for byte; keep it that way when moving more strings (provider payload tests and tests/int/i18n-server.int.spec.ts check it).

Payload admin labels and descriptions use adminT('marmot:<collection>:<field>Description') with the text in src/i18n/admin.ts (Payload's own {{var}} placeholders, not ICU). Function descriptions are not emitted into src/payload-types.ts, so the generated types carry no field JSDoc. Select options that are words use adminT('marmot:labels:…') (product names such as PostgreSQL stay literal), and sidebar groups use admin.group: adminGroup('monitoring'), which builds Payload's per-language map from the same catalogue.

Locale resolution

User preference (users.language) → marmot-locale cookie (signed-out pages; the language picker sets it) → Accept-Language matched by base language → defaultLocale. Public status pages use their own language prefix in the URL (/status/<slug>/fr), else their defaultLocale; plain URLs redirect first visits to an offered language the visitor prefers (src/lib/status-page-locales.ts). <html lang> always reflects the result. See docs/Status-Pages.md → Languages and time zones.

Adding a language

The signed-in app only offers appLocales, whose catalogues are complete. A language can first ship for the public status pages only: add <locale>.json with exactly the keys in PUBLIC_MESSAGE_SCOPES, register it in publicCatalogues (src/i18n/messages/index.ts), append it to locales and localeNames, and add it to the language prefixes in the matcher of src/proxy.ts. A string added to one of those scopes in en.json must be translated in every such catalogue (the catalogue test checks keys and placeholders). To make a language complete:

  1. Copy src/i18n/messages/en.json to <locale>.json and translate it (same keys; the catalogue format is plain nested JSON with ICU messages, which Crowdin, Weblate and Lingo.dev all handle).
  2. Register it in src/i18n/messages/index.ts and append the locale to locales, appLocales and localeNames in src/i18n/locales.ts. The language picker in Settings → Account and the status page builder appear automatically once more than one locale exists.
  3. For the Payload admin, add the matching pack from @payloadcms/translations/languages/<locale> and translate the marmot: keys in src/i18n/admin.ts (including groups, labels and the maintenance option maps).
  4. Run pnpm check: the typecheck and the catalogue test fail on missing keys.

Migrations

Postgres uses Payload migrations in src/migrations/postgres (chained in index.ts); MongoDB creates indexes on boot and only needs a migration (src/migrations/mongodb, chained the same way) to drop an index that changed or to fix data, since Mongoose never drops an index by itself. After changing a collection, global or field:

pnpm generate:types
DATABASE_URL=postgres://marmot:marmot@localhost:5432/marmot_fresh pnpm migrate:create <name> --force-accept-warning

Create the migration against a fresh database that has only the existing migrations applied, so the diff contains exactly your change: createdb marmot_fresh, then NODE_ENV=production DATABASE_URL=… pnpm migrate --force-accept-warning to bring it to main, then migrate:create. Never edit generated migration files by hand. If main gains a migration while your branch is open, delete yours, restore src/migrations/postgres/index.ts from main, and regenerate on a fresh database.

Migrations run automatically when the web role starts in the Docker image (dist/server/migrate.mjs, SKIP_MIGRATIONS=true opts out) and can be run by hand with pnpm migrate (development) or pnpm migrate:prod (from the bundle). DATABASE_ADAPTER=mongodb pnpm migrate:create creates a MongoDB migration for the rare data or index migration.

Server bundles

The Docker image does not ship TypeScript tooling. pnpm build:server (scripts/build-server.mjs) bundles src/worker.ts, src/realtime.ts and src/cli/migrate.ts into dist/server/{worker,realtime,migrate}.mjs with esbuild, keeping every package external. Consequences for server code:

  • no import.meta.url-relative paths to repository files (use process.cwd());
  • no dynamic imports with computed specifiers; optional drivers are imported with a literal specifier;
  • run pnpm build:server once when you touch src/worker.ts, src/realtime.ts, src/server/**, src/collections/** or src/db/**.

pnpm start:worker, pnpm start:realtime and pnpm migrate:prod run the bundles locally.

The GitHub Action is bundled separately (pnpm build:action, scripts/build-action.mjs) into action/dist/index.mjs, which is committed: run pnpm build:action and commit the result when you change src/action/**, src/cli/**, the monitor validation (src/lib/validation/**) or the cli / monitors.validation messages. The GitHub Action workflow fails when the committed bundle is stale.

Where things are

src/payload.config.ts            buildConfig; wires collections, plugins, db factory, email
src/env.ts                       zod-validated env (the only place process.env is read)
src/collections/                 one file per collection; register in index.ts
src/globals/                     instance settings
src/access/                      org RBAC helpers (orgScoped, permissions map)
src/auth/sso/                    single sign-on (payload-auth plugin wiring, providers, user matching)
src/server/engine/               BullMQ queues, schedulers, check worker, heartbeat state machine
src/server/monitor-types/        one file per type, self-registering
src/server/notification-providers/ one file per provider, self-registering
src/server/notifications/        dispatch, templates, notification worker
src/server/stats/                uptime calculator and rollups
src/server/realtime/             socket.io event names, emitter helpers, server auth
src/server/status-pages/         public payload, RSS, route helpers
src/app/(frontend)/              Marmot UI (App Router)
src/i18n/                        locales, message catalogues, formats, request locale (next-intl)
src/app/api/                     route handlers (org-scoped and public)
src/components/                  UI; shadcn primitives under components/ui
src/stores/                      Zustand stores fed by the socket client
tests/int, tests/e2e             Vitest integration tests, Playwright E2E
docker/                          Dockerfile, compose files, Caddyfile, entrypoint

Extension points (adding a monitor type or a notification provider) are described in CONTRIBUTING.md; the data flow is in Architecture.

Clone this wiki locally