Database example

Try it
Start your own copy of this example:
npm create @setu-ts my-app -- --example database
cd my-app && npm install
npm start
The commands further down run inside a clone of the repository.

This app uses DatabasePlugin({ type: 'memory' }), so it has no database service prerequisite. Its routes create, read, and update a note through the public repository interface. The smoke check also opens a transaction, writes a row, throws, and confirms that the rollback leaves the row count unchanged.

pnpm --dir apps/database run start
pnpm --dir apps/database run smoke

Read the example deep dive for the concept and source walkthrough.

Read, in order

src/modules/notes/index.ts

import type { IRouterApi } from '@setu-ts/common';
import { registerNotesRoutes } from './notes.controller.ts';

/** Registers the notes feature. */
export function registerNotesModule(router: IRouterApi): void {
  registerNotesRoutes(router);
}

src/modules/notes/notes.controller.ts

import type { IRouterApi } from '@setu-ts/common';
import { notFound } from '@setu-ts/exceptions';
import { validateBody } from '@setu-ts/validation-plugin';
import { CreateNoteBody, UpdateNoteBody } from './notes.schemas.ts';
import { notesRepository } from './notes.service.ts';

const createBody = validateBody(CreateNoteBody);
const updateBody = validateBody(UpdateNoteBody);

/** Registers the notes routes over the database repository. */
export function registerNotesRoutes(router: IRouterApi): void {
  router.group('/notes', (routes) => {
    routes.post('/', {
      middleware: [createBody],
      handler: async (ctx) => {
        const note = await notesRepository(ctx.services).create(createBody.value(ctx));
        return ctx.response.status(201).json(note);
      },
    });

    routes.get('/:id', async (ctx) => {
      const note = await notesRepository(ctx.services).findById(ctx.params.id);
      if (note === null) throw notFound(`Note ${ctx.params.id} was not found.`);
      return ctx.response.json(note);
    });

    routes.patch('/:id', {
      middleware: [updateBody],
      handler: async (ctx) => {
        const notes = notesRepository(ctx.services);
        if ((await notes.findById(ctx.params.id)) === null) {
          throw notFound(`Note ${ctx.params.id} was not found.`);
        }
        return ctx.response.json(await notes.update(ctx.params.id, updateBody.value(ctx)));
      },
    });
  });
}

src/modules/notes/notes.schemas.ts

import { z } from 'zod';

export const CreateNoteBody = z.object({ id: z.string().min(1), text: z.string().min(1) });
export type CreateNoteBody = z.infer<typeof CreateNoteBody>;

export const UpdateNoteBody = CreateNoteBody.pick({ text: true });
export type UpdateNoteBody = z.infer<typeof UpdateNoteBody>;

export type Note = CreateNoteBody;

src/modules/notes/notes.service.ts

import { CAPABILITIES } from '@setu-ts/common';
import type { IServiceRegistry } from '@setu-ts/common';
import type { IDatabaseService, IRepository } from '@setu-ts/database-plugin';
import type { Note } from './notes.schemas.ts';

/** The entity name every notes repository is addressed by. */
export const NOTES_ENTITY = 'notes';

/** Resolves the notes repository from the application's database capability. */
export function notesRepository(services: IServiceRegistry): IRepository<Note> {
  return services.get<IDatabaseService>(CAPABILITIES.DATABASE).getRepository<Note>(NOTES_ENTITY);
}

smoke.ts

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { CAPABILITIES } from '@setu-ts/common';
import type { IDatabaseService } from '@setu-ts/database-plugin';
import type { IKernelApplication } from '@setu-ts/kernel';
import type { Note } from './src/modules/notes/notes.schemas.ts';
import { NOTES_ENTITY } from './src/modules/notes/notes.service.ts';
import { createApp } from './setu.config.ts';

describe('database example', () => {
  let app: IKernelApplication;

  before(async () => {
    app = createApp();
    await app.start();
  });

  after(async () => {
    await app.stop();
  });

  it('reads back a note it wrote', async () => {
    const created = await app.inject({
      method: 'POST',
      url: '/notes',
      body: { id: 'first', text: 'Write this note' },
    });
    assert.equal(created.statusCode, 201);

    const read = await app.inject({ method: 'GET', url: '/notes/first' });

    assert.equal(read.statusCode, 200);
    assert.equal(read.json<Note>().text, 'Write this note');
  });

  it('persists an update', async () => {
    const updated = await app.inject({
      method: 'PATCH',
      url: '/notes/first',
      body: { text: 'Read the updated note' },
    });
    assert.equal(updated.statusCode, 200);

    const reread = await app.inject({ method: 'GET', url: '/notes/first' });

    assert.equal(reread.json<Note>().text, 'Read the updated note');
  });

  it('answers 404 for a note that does not exist', async () => {
    const read = await app.inject({ method: 'GET', url: '/notes/missing' });
    const patched = await app.inject({
      method: 'PATCH',
      url: '/notes/missing',
      body: { text: 'x' },
    });

    assert.equal(read.statusCode, 404);
    assert.equal(patched.statusCode, 404);
  });

  it('discards the writes of a transaction that throws', async () => {
    const database = app.services.get<IDatabaseService>(CAPABILITIES.DATABASE);
    const notes = database.getRepository<Note>(NOTES_ENTITY);
    const countBefore = await notes.count();

    await assert.rejects(
      database.transaction(async (unitOfWork) => {
        await unitOfWork.getRepository<Note>(NOTES_ENTITY).create({ id: 'rolled-back', text: '-' });
        throw new Error('roll back');
      }),
      /roll back/,
    );

    assert.equal(await notes.count(), countBefore);
  });
});