Developer Cheat Sheet - jra3/mulm GitHub Wiki

Developer Cheat Sheet

Quick reference for common development tasks, commands, and code patterns in the Mulm project.


Common Commands

Development

npm run dev              # Start dev server with hot reload
npm run build            # Build TypeScript + CSS
npm start                # Start production build
npm run lint             # Check code style
npm run lint:fix         # Auto-fix linting issues

Testing

npm test                                    # Run all tests
npm test -- src/__tests__/mytest.test.ts   # Run specific test
npm run test:watch                          # Watch mode

Database

sqlite3 db/database.db               # Open DB CLI (development)
sqlite3 db/database.db ".schema"     # Show schema
sqlite3 db/database.db "SELECT * FROM members;" # Query
# For production, use: ssh BAP "sqlite3 /mnt/basny-data/app/database/database.db"

Deployment

ssh BAP "cd /opt/basny && git pull && sudo docker-compose -f docker-compose.prod.yml up -d --build"

Project Structure

src/
├── routes/          # HTTP handlers → res.render() or res.json()
├── db/              # Database queries → query(), insertOne(), updateOne()
├── forms/           # Zod schemas → validate user input
├── utils/           # Helpers → r2-client, logger, image-processor
├── views/           # Pug templates → HTML rendering
└── __tests__/       # Tests → *.test.ts

Code Snippets

Adding a Route

// 1. Create handler in src/routes/myfeature.ts
import { MulmRequest } from '@/sessions';
import { Response } from 'express';

export const showMyFeature = async (req: MulmRequest, res: Response) => {
  const { viewer } = req; // Current user (or undefined)

  res.render('myfeature', {
    title: 'My Feature',
    viewer,
  });
};

// 2. Register in src/index.ts
import { showMyFeature } from './routes/myfeature';
app.get('/myfeature', showMyFeature);

// 3. Create view in src/views/myfeature.pug
extends layout
block content
  h1 My Feature

Database Query

// Read query
import { query } from '@/db/conn';

const members = await query<MemberRecord>(
  'SELECT * FROM members WHERE is_admin = ?',
  [1]
);

// Insert
import { insertOne } from '@/db/conn';

await insertOne('members', {
  contact_email: '[email protected]',
  display_name: 'User Name',
  is_admin: 0,
});

// Update
import { updateOne } from '@/db/conn';

await updateOne(
  'members',
  { id: memberId },                    // WHERE clause
  { display_name: 'New Name' }         // SET clause
);

// Transaction
import { withTransaction } from '@/db/conn';

await withTransaction(async (db) => {
  await db.run('INSERT INTO members ...');
  await db.run('UPDATE submissions ...');
  // Atomic - both succeed or both rollback
});

Form Validation

// 1. Define schema in src/forms/myform.ts
import { z } from 'zod';

export const myFormSchema = z.object({
  name: z.string().min(2, 'Name too short'),
  email: z.string().email('Invalid email'),
  age: z.number().min(18, 'Must be 18+'),
});

// 2. Validate in route handler
import { myFormSchema } from '@/forms/myform';

const result = myFormSchema.safeParse(req.body);
if (!result.success) {
  // Show errors
  const errors = new Map<string, string>();
  result.error.issues.forEach(issue => {
    errors.set(issue.path[0].toString(), issue.message);
  });

  res.render('myform', { errors, form: req.body });
  return;
}

// result.data is typed and validated
const { name, email, age } = result.data;

Accessing Current User

import { MulmRequest } from '@/sessions';

export const myRoute = async (req: MulmRequest, res: Response) => {
  const { viewer } = req; // MemberRecord | undefined

  // Check if logged in
  if (!viewer) {
    return res.redirect('/auth/signin');
  }

  // Check if admin
  if (!viewer.is_admin) {
    return res.status(403).send('Forbidden');
  }

  // Use viewer data
  console.log(`User ${viewer.display_name} (ID: ${viewer.id})`);
};

Writing a Test

import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { setupTestDatabase, createTestMembers } from './testDbHelper.helper';

describe('My Feature', () => {
  let testDb;

  beforeEach(async () => {
    testDb = await setupTestDatabase();
  });

  afterEach(async () => {
    await testDb.cleanup();
  });

  test('should do something', async () => {
    // Arrange
    const [member] = await createTestMembers(1);

    // Act
    const result = await myFunction(member.id);

    // Assert
    assert.strictEqual(result, expected);
  });
});

Creating a Migration

-- db/migrations/016-add-my-field.sql
-- Up

ALTER TABLE submissions ADD COLUMN my_field TEXT DEFAULT NULL;
CREATE INDEX idx_submissions_my_field ON submissions(my_field);

-- Down

DROP INDEX IF EXISTS idx_submissions_my_field;
-- Note: SQLite doesn't support DROP COLUMN

Pug Template Patterns

//- Conditional rendering
if viewer
  p Welcome, #{viewer.display_name}!
else
  a(href="/auth/signin") Sign In

//- Loop
each submission in submissions
  div.submission-card
    h3= submission.species_common_name
    p Points: #{submission.points}

//- HTMX dynamic content
button(
  hx-post=`/admin/submissions/${submission.id}/approve`
  hx-target="#submission-list"
  hx-swap="outerHTML"
) Approve

//- Long Tailwind classes
div(
  class="bg-gradient-to-r from-yellow-50 to-amber-50" +
        " rounded-lg shadow-lg p-6"
)

//- Include mixin
include mixins/imageUpload
+imageUploadSection()

Environment Variables

Development

NODE_ENV=development  # Auto-set by npm run dev

Testing

NODE_ENV=test         # Auto-set by npm test
# Silences logger, skips email sending

Production

NODE_ENV=production   # Set in docker-compose.prod.yml

Database Helpers

Test Database Setup

import { setupTestDatabase, createTestMembers } from './testDbHelper.helper';

const testDb = await setupTestDatabase();      // Fresh DB with migrations
const members = await createTestMembers(3);    // 3 test members
await testDb.cleanup();                        // Close connection

Common Queries

// Get single record
const member = await query<MemberRecord>(
  'SELECT * FROM members WHERE id = ?',
  [id]
).then(rows => rows[0] || null);

// Get multiple records
const submissions = await query<Submission>(
  'SELECT * FROM submissions WHERE member_id = ? ORDER BY created_on DESC',
  [memberId]
);

// Count
const count = await query<{ count: number }>(
  'SELECT COUNT(*) as count FROM members'
).then(rows => rows[0].count);

Git Workflow

Before Starting Work

git checkout main
git pull upstream main
git checkout -b feature/my-feature

During Development

git add .
git commit -m "feat(scope): description"
git push origin feature/my-feature

Updating from Main

git checkout main
git pull upstream main
git checkout feature/my-feature
git rebase main
git push origin feature/my-feature --force-with-lease

Debugging

Debug Server

// Add breakpoints in VS Code
// Press F5 to start debugging
// Or add console.log (removed by linter if you forget)
console.log('Debug:', variable);

Debug Database

# In test
test('debug db', async () => {
  const all = await db.all('SELECT * FROM members');
  console.log(all);
});

# In production
ssh BAP "sqlite3 /mnt/basny-data/app/database/database.db"
sqlite> SELECT * FROM submissions WHERE id = 123;

Debug HTTP Requests

# curl
curl -v https://bap.basny.org/api/species/search?q=guppy

# or browser DevTools Network tab

Common Patterns

Require Authentication

export const myRoute = async (req: MulmRequest, res: Response) => {
  if (!req.viewer) {
    return res.redirect('/auth/signin');
  }
  // ... authenticated route logic
};

Require Admin

import { requireAdmin } from './routes/admin';

router.use('/admin/*', requireAdmin);
// Or inline:
export const myRoute = async (req: MulmRequest, res: Response) => {
  if (!req.viewer?.is_admin) {
    return res.status(403).send('Forbidden');
  }
  // ... admin logic
};

Parse JSON from Database

const submission = await getSubmission(id);

// Parse JSON fields
const foods = JSON.parse(submission.foods) as string[];
const images = JSON.parse(submission.images || '[]') as ImageMetadata[];

Render with HTMX

// Return full page
res.render('admin/queue', { submissions });

// Return partial (HTMX target)
res.render('admin/singleMemberRow', { member });

TypeScript Path Aliases

import { query } from '@/db/conn';           // src/db/conn.ts
import { MulmRequest } from '@/sessions';    // src/sessions.ts
import config from '@/config.json';          // src/config.json
import { logger } from '@/utils/logger';     // src/utils/logger.ts

Configured in: tsconfig.jsonpaths


Useful Links

Documentation

External Resources


Bookmark this page for quick access to common patterns!

⚠️ **GitHub.com Fallback** ⚠️