Skip to content

Tutorial: a blog ​

This tutorial builds a small blog with each part of the library in turn: schemas, a migration, a sign-up form validated with Zod, queries and preloads, a form that edits a post together with its comments, a batch that publishes a post and notifies readers, and tests that run each case in a transaction that is rolled back.

The code on this page is the source of a working application, examples/blog in the repository, and its test suite runs in CI. Every block starts with the name of its file. You can read the page from top to bottom, or clone the repository and run it (see the end).

It takes about thirty minutes. You need Node.js 22.18 or later and a Postgres server, 14 or later. (The egon command loads your TypeScript configuration and migrations with Node's own type stripping, which 22.18 turns on by itself; on 22.12 to 22.17, see the CLI.)

1. Set up the project ​

sh
mkdir blog && cd blog
npm init -y
npm pkg set type=module
npm install egon-orm @egon-orm/postgres pg zod
npm install -D @egon-orm/cli typescript vitest @types/node @types/pg
mkdir -p src/migrations test

Egon is published as ES modules only, hence "type": "module".

@egon-orm/cli is the egon command: it writes migration files, applies them, and creates databases. It is a development dependency, run with npx egon.

Add a tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true
  },
  "include": ["src", "test", "egon.config.ts", "vitest.config.ts"]
}

Node.js and Vitest run the TypeScript; npx tsc checks its types (@types/pg is for tsc: the adapter's types refer to pg's). The imports between your own files end in .js, as Node.js expects.

Start a database and tell the application where it is:

sh
docker run -d --name blog-db -e POSTGRES_PASSWORD=blog -e POSTGRES_DB=blog -p 5432:5432 postgres:16
export DATABASE_URL=postgres://postgres:blog@localhost:5432/blog

POSTGRES_DB=blog creates the blog's own database, so its tables do not land in the server's default postgres database. Without Docker, any Postgres 14 or later works (a local install or a hosted one): point DATABASE_URL at a database named blog there, and create it with npx egon db create once the configuration of step 3 exists.

2. Connect ​

The repo is the one object that talks to the database. It takes an adapter, which wraps the driver (pg here):

ts
// src/db.ts
import { postgres } from "@egon-orm/postgres";
import { createRepo } from "egon-orm";

export const adapter = postgres({ connectionString: process.env.DATABASE_URL });
export const repo = createRepo({ adapter });

Nothing connects until the first query. The repo is an ordinary value: other files import it.

3. Create the tables ​

A migration describes a change to the database. egon reads where the migrations are, and which database they run on, from egon.config.ts at the root of the project:

ts
// egon.config.ts: what `npx egon` reads. DATABASE_URL names the database it works on.
import { defineConfig } from "@egon-orm/cli";
import { postgres } from "@egon-orm/postgres";

const databaseUrl = process.env.DATABASE_URL;
if (databaseUrl === undefined) throw new Error("Set DATABASE_URL to the database to migrate.");
const url = new URL(databaseUrl);
const admin = new URL(url);
admin.pathname = "/postgres"; // the same server's default database

export default defineConfig({
  adapter: postgres({ connectionString: url.href }),
  migrations: { directory: "src/migrations" },
  // For `egon db create | drop | reset`: the database DATABASE_URL names, created or
  // dropped through the server's `postgres` database.
  database: {
    name: decodeURIComponent(url.pathname.slice(1)),
    admin: postgres({ connectionString: admin.href }),
  },
});

DATABASE_URL (step 1) names the database. database is for egon db (step 10): the name of the database to create, and an adapter connected to the same server's postgres database, through which CREATE DATABASE runs.

Then let egon write the migration file:

sh
npx egon migrate create create_blog

It prints the path of a new file in src/migrations, such as src/migrations/20261020090000_create_blog.ts: its name starts with its version (the current UTC time, YYYYMMDDHHMMSS) and then its name. The file has an empty up and down. Fill them in:

ts
// src/migrations/20261020090000_create_blog.ts
import { defineMigration } from "egon-orm/migration";

export const migration = defineMigration({
  up(m) {
    m.createTable("users", (table) => {
      table.id();
      table.text("email").notNull().unique(); //  users_email_key
      table.text("name").notNull();
      table.text("password_hash").notNull();
      table.timestamps();
    });

    m.createTable("posts", (table) => {
      table.id();
      table.references("author_id", "users").notNull(); // posts_author_id_fkey
      table.text("title").notNull();
      table.text("body").notNull();
      table.timestamptz("published_at");
      table.timestamps();
    });

    m.createTable("comments", (table) => {
      table.id();
      table.references("post_id", "posts", { onDelete: "cascade" }).notNull();
      table.text("author_name").notNull();
      table.text("body").notNull();
      table.timestamps();
    });

    // Who follows whom: a reader is told when an author they follow publishes.
    m.createTable("subscriptions", (table) => {
      table.references("reader_id", "users", { onDelete: "cascade" }).notNull().primaryKey();
      table.references("author_id", "users", { onDelete: "cascade" }).notNull().primaryKey();
    });

    m.createTable("notifications", (table) => {
      table.id();
      table.references("user_id", "users", { onDelete: "cascade" }).notNull();
      table.references("post_id", "posts", { onDelete: "cascade" }).notNull();
      table.timestamptz("read_at");
      table.timestamps();
    });
  },

  // The undo: the tables in the reverse order, so that no foreign key points at a dropped one.
  down(m) {
    for (const name of ["notifications", "subscriptions", "comments", "posts", "users"]) {
      m.dropTable(name);
    }
  },
});

up makes the change and down undoes it, so the migration can be rolled back. table.references adds a foreign key, and .unique() a unique index named users_email_key; the changesets below refer to these names. The table builder is named table because t is the field builder of the schema classes, imported from egon-orm.

Apply it:

sh
npx egon migrate

egon migrate applies every pending migration, each in its own transaction, and prints one line per migration; npx egon migrations lists them with their state. The Migrations guide covers rollbacks (npx egon rollback) and deployments, and the CLI page lists every command.

4. Describe the tables ​

A schema is a class that describes one table. Its instances are the rows the database returns, so the class name is the row type. All the classes of this step go in one file, src/schemas.ts, which starts with:

ts
// src/schemas.ts: the blog's tables as schema classes. The classes below all go in this file.
import { belongsTo, cast, hasMany, Schema, t, timestamps } from "egon-orm";
ts
export class User extends Schema("users", {
  id: t.bigint({ mode: "number" }).primaryKey().dbGenerated(), // bigserial: the database makes it
  email: t.string(),
  name: t.string(),
  password: t.string().virtual().redact(), // in changesets only: never stored, never logged
  passwordHash: t.string().redact(), //       hidden from logs, errors and JSON
  ...timestamps(), //                    createdAt, updatedAt
}) {
  static posts = hasMany(() => Post, { foreignKey: "authorId" });
}
  • Fields are non-null unless they say .nullable(): name is a string, never null.
  • Field names are camelCase and columns snake_case: passwordHash is the column password_hash.
  • password is .virtual(): a sign-up form can send it, but it is never stored.
  • There is no new User(...). A User comes only from the database.

A post belongs to its author, and has comments:

ts
export class Post extends Schema("posts", {
  id: t.bigint({ mode: "number" }).primaryKey().dbGenerated(),
  authorId: t.bigint({ mode: "number" }),
  title: t.string(),
  body: t.string(),
  publishedAt: t.timestamp().nullable(),
  ...timestamps(),
}) {
  static author = belongsTo(() => User, { foreignKey: "authorId" });
  // Comments that a form leaves out are deleted when the post is saved.
  static comments = hasMany(() => Comment, {
    foreignKey: "postId",
    orderBy: ["createdAt"],
    onReplace: "delete",
  });

  static changeset(post: Post | typeof Post, params: unknown) {
    return cast(post, params, ["title", "body"])
      .validateRequired(["title", "body"])
      .validateLength("title", { max: 200 });
  }

  get isPublished(): boolean {
    return this.publishedAt !== null;
  }
}

Associations are static members that point at the other class through a function, so two classes can refer to each other. The foreign key, authorId, is an ordinary field you declare. A schema class can have getters and methods, like isPublished, and changeset functions: static methods that say which fields a form may change and how to check them.

ts
export class Comment extends Schema("comments", {
  id: t.bigint({ mode: "number" }).primaryKey().dbGenerated(),
  postId: t.bigint({ mode: "number" }),
  authorName: t.string(),
  body: t.string(),
  ...timestamps(),
}) {
  static post = belongsTo(() => Post, { foreignKey: "postId" });

  static changeset(comment: Comment | typeof Comment, params: unknown) {
    return cast(comment, params, ["authorName", "body"])
      .validateRequired(["authorName", "body"])
      .validateLength("body", { max: 2000 });
  }
}

Last, the two tables that step 9 uses, for readers who follow an author and the notifications they get:

ts
export class Subscription extends Schema("subscriptions", {
  readerId: t.bigint({ mode: "number" }).primaryKey(), // a composite key: both are given, not generated
  authorId: t.bigint({ mode: "number" }).primaryKey(),
}) {}

export class Notification extends Schema("notifications", {
  id: t.bigint({ mode: "number" }).primaryKey().dbGenerated(),
  userId: t.bigint({ mode: "number" }),
  postId: t.bigint({ mode: "number" }),
  readAt: t.timestamp().nullable(),
  ...timestamps(),
}) {}

5. Sign up with a changeset ​

Writes go through a changeset: a value that holds the input a form sent, filtered to the fields it may change and converted to their types, with the validation errors and the database constraints to watch for. It never touches the database.

ts
// src/accounts.ts: signing up
import { randomBytes, scryptSync } from "node:crypto";
import { type Changeset, cast } from "egon-orm";
import { z } from "zod";
import { repo } from "./db.js";
import { User } from "./schemas.js";

const Registration = z.object({
  email: z.email(),
  name: z.string().trim().min(1).max(80),
  password: z.string().min(12),
});

export function registrationChangeset(params: unknown) {
  return cast(User, params, ["email", "name", "password"])
    .validate(Registration) // every issue becomes an error on its field, one per field
    .uniqueConstraint("email") // a duplicate email becomes { email: ["has already been taken"] }
    .pipe(hashPassword);
}

// A step of the changeset: hash the password once the input is known to be valid.
function hashPassword(cs: Changeset<typeof User, true>) {
  const password = cs.getChange("password");
  if (!cs.valid || password === undefined) return cs;
  const salt = randomBytes(16).toString("hex");
  return cs.putChange("passwordHash", `${salt}:${scryptSync(password, salt, 32).toString("hex")}`);
}

cast keeps only email, name and password from params: a request that also sends passwordHash or id has no effect. The Zod schema reports every problem at once, each on its field, and a field gets one message: name: "" is can't be blank (cast turns the empty string into null, which a non-null field refuses), and Zod's own complaint about the missing value is not added. uniqueConstraint("email") tells the repo that a violation of users_email_key is a form error, not a crash. pipe applies a step of your own: here, hashing the password once the input is valid.

The repo writes the changeset and returns a result, never a half-saved row. In the same file:

ts
export async function register(params: unknown) {
  const result = await repo.insert(registrationChangeset(params));
  if (!result.success) return { errors: result.error.errorMessages() };
  return { user: result.data };
}

result.success tells the two cases apart. On failure, result.error is the changeset with its errors; errorMessages() gives { email: ["has already been taken"] }, ready for a form. Every write also has a throwing twin, insertOrThrow, for code where a failure is a bug.

6. Write posts ​

The functions of steps 6 and 7 go in src/posts.ts, which starts with:

ts
// src/posts.ts: writing and reading posts. The functions below all go in this file.
import { count, from, type Loaded } from "egon-orm";
import { repo } from "./db.js";
import { Post, type User } from "./schemas.js";
ts
export function createPost(author: User, params: unknown) {
  // The author comes from the session, never from the form: it is set, not cast.
  return repo.insert(Post.changeset(Post, params).putChange("authorId", author.id));
}

export function updatePost(post: Post, params: unknown) {
  return repo.update(Post.changeset(post, params));
}

The author is put on the changeset with putChange, not cast from the form: a reader cannot post in someone else's name by sending authorId. Post.changeset takes the class for a new post and a loaded row for an update; an update writes only the columns that changed.

7. Read with queries ​

A query is an immutable value. Building one runs nothing, so a base query can be kept in a constant and extended:

ts
// A query is a value: build it once, reuse it, extend it.
export const published = from(Post, "p").where(({ p }) => p.publishedAt.isNotNull());

export function feed(page: number, perPage = 10) {
  return repo.all(
    published
      .orderBy(({ p }) => p.publishedAt.desc())
      .limit(perPage)
      .offset((page - 1) * perPage)
      .preload("author"), // one more query for every author on the page, not one per post
  ); // type: Promise<Loaded<typeof Post, "author">[]>
}

from(Post, "p") names the table p, its binding; the callbacks receive the bindings by name. Every value in a query is sent as a bound parameter. preload("author") loads the authors of the whole page in one more query, whatever the number of posts.

Nothing is loaded lazily. A preloaded association is in the row's type, so the compiler knows which rows have their author and comments:

ts
export async function postPage(id: number) {
  const post = await repo.one(published.where({ id }).preload(["author", "comments"]));
  return post; // Loaded<typeof Post, "author" | "comments"> | null
}

export function render(post: Loaded<typeof Post, "author" | "comments">): string {
  return [
    `${post.title}, by ${post.author.name}`,
    ...post.comments.map((c) => `  ${c.authorName}: ${c.body}`),
  ].join("\n");
}

Calling render with a post loaded without its comments is a compile error, not an undefined at run time.

A select decides the shape and the type of the result:

ts
export function authorStats() {
  return repo.all(
    from(Post, "p")
      .joinAssoc("p", "author", "u")
      .where(({ p }) => p.publishedAt.isNotNull())
      .groupBy(({ u }) => [u.id, u.name])
      .orderBy(({ p }) => count(p.id).desc())
      .select(({ u, p }) => ({ author: u.name, posts: count(p.id) })),
  ); // { author: string; posts: number }[]
}

joinAssoc writes the join condition from the association.

8. A form with nested rows ​

This step's two functions go in src/comments.ts:

ts
// src/comments.ts: adding comments, and the moderation form that edits a post together
// with its comments. Both functions go in this file.
import { from } from "egon-orm";
import { repo } from "./db.js";
import { Comment, Post } from "./schemas.js";

Readers comment:

ts
export function addComment(post: Post, params: unknown) {
  return repo.insert(Comment.changeset(Comment, params).putChange("postId", post.id));
}

A moderator edits a post and its comments in one form: fix a typo in one comment, delete spam, reply. The changeset of the post carries a changeset for each comment:

ts
// params: { title, body, comments: [{ id, authorName, body }, …] }
export async function moderate(postId: number, params: unknown) {
  const post = await repo.oneOrThrow(from(Post, "p").where({ id: postId }).preload("comments"));

  const changeset = Post.changeset(post, params).castAssoc("comments"); // uses Comment.changeset
  return repo.update(changeset); // the post and its comments, in one transaction
}

castAssoc("comments") reads params.comments, matches each item to an existing comment by id (an update) or creates one (no id), and validates each with Comment.changeset. Comments the form leaves out are deleted, because the association says onReplace: "delete"; without it, leaving one out is an error, so nothing is ever removed by accident. The association has to be preloaded first: the changeset compares the form with what is there.

repo.update writes the post and every comment in one transaction. If one comment is invalid, nothing is written and the errors come back nested, one entry per submitted comment, in the order of the form: a form that keeps an existing comment as it is and adds one with an empty body gets { comments: [{}, { body: ["can't be blank"] }] }. The {} says that the first item is valid, so a form can show each item's errors next to it.

9. Publish and notify, in one batch ​

Publishing sets publishedAt and creates a notification for each reader who follows the author (the Subscription and Notification classes of step 4).

A batch is a list of named steps that run in one transaction. Each step can use the results of the steps before it, by name and fully typed. In src/publishing.ts:

ts
// src/publishing.ts: publishing a post and notifying the author's readers, as one batch.
import { change, createBatch, failure, from, success } from "egon-orm";
import { repo } from "./db.js";
import { Notification, Post, Subscription } from "./schemas.js";
ts
export function publishBatch(postId: number, now: Date = new Date()) {
  return createBatch()
    .one("post", from(Post, "p").where({ id: postId }).lock("update")) // read again, locked
    .run("unpublished", async (_tx, { post }) =>
      post === null
        ? failure("no such post")
        : post.isPublished
          ? failure("already published")
          : success(post),
    )
    .update("published", ({ unpublished }) => change(unpublished, { publishedAt: now }))
    .all("readers", ({ unpublished }) => from(Subscription, "s").where({ authorId: unpublished.authorId }))
    .insertAll("notifications", Notification, ({ unpublished, readers }) =>
      readers.map((s) => ({ userId: s.readerId, postId: unpublished.id })),
    );
}

export async function publish(postId: number) {
  const result = await repo.transaction(publishBatch(postId));
  if (!result.success) {
    // type: "unpublished" | "published", the steps that can fail
    const failedAt = result.error.step;
    return { published: false, failedAt, error: result.error.error } as const;
  }
  return { published: true, post: result.data.published, notified: result.data.notifications.count } as const;
}

Three functions appear here for the first time. change(row, values) is the changeset of values you trust, such as a timestamp your code computed: unlike cast, it filters and converts nothing (Creating a changeset). A run step returns success(value) to go on, with value as its result, or failure(error) to stop the batch and roll it back (Steps).

The batch takes the post's id and reads the post again, locked, as its first step. A Post object in memory is a copy of the row as it was read: one read before someone else published the post still says publishedAt: null, so a guard that trusted it would publish twice. Reading the row in the transaction, with lock("update"), makes the guard see the current row, and keeps a second publisher waiting until the first one's transaction ends.

If any step fails, everything is rolled back and the result says which step failed and what had already completed. result.error.step is typed as the names of the steps that can fail, here "unpublished" | "published" (the run step that may return failure, and the update); a read such as one cannot fail, so it is not in the union. publish returns published: true or false beside the rest, so a caller can tell the two cases apart with one check. Because a batch is a value, a test can check its steps without running it (see below).

10. Test it ​

Tests need a database of their own. Create it with egon db create, with DATABASE_URL naming it for that one command:

sh
DATABASE_URL=postgres://postgres:blog@localhost:5432/blog_test npx egon db create

egon.config.ts (step 3) takes the name of the database to create, blog_test, from DATABASE_URL.

A Vitest setup file brings its tables up to date before the tests run:

ts
// test/setup.ts: a Vitest setup file (setupFiles in vitest.config.ts), run before each test
// file. DATABASE_URL points at a database for the tests; this brings its tables up to date
// with the runner `egon migrate` uses, so `npx vitest run` needs no step before it.
import { fileURLToPath } from "node:url";
import { createMigrator } from "egon-orm/migration";
import { afterAll } from "vitest";
import { adapter } from "../src/db.js";

const directory = fileURLToPath(new URL("../src/migrations", import.meta.url));
await createMigrator({ adapter, directory }).up(); // after the first file: nothing to apply

afterAll(() => adapter.close());

It calls createMigrator, the runner behind egon migrate, rather than the command, so the tests always run against the current tables and npx vitest run needs nothing before it.

The Vitest configuration names the test files and the setup file:

ts
// vitest.config.ts: the blog's tests are test/*.itest.ts, and test/setup.ts runs before each
// file. Run them with DATABASE_URL pointing at the test database: npx vitest run
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["test/**/*.itest.ts"],
    setupFiles: ["test/setup.ts"],
  },
});

Then run the tests against the test database:

sh
DATABASE_URL=postgres://postgres:blog@localhost:5432/blog_test npx vitest run

With another Postgres, the URL names your server and the test database: postgres://<user>:<password>@<host>:<port>/blog_test.

egon-orm/testing has a sandbox: each test runs in a transaction that is rolled back afterwards, so tests see their own writes, start from empty tables, and need no cleanup. It works with the repo your application already imports, and test files that run at the same time do not see each other's rows. Every test file sets it up for itself:

ts
// test/accounts.itest.ts
import { createSandbox } from "egon-orm/testing";
import { afterEach, beforeEach, expect, test } from "vitest";
import { register } from "../src/accounts.js";
import { repo } from "../src/db.js";
import { User } from "../src/schemas.js";

const sandbox = await createSandbox(repo);
beforeEach(() => sandbox.begin()); //  every test runs in a transaction…
afterEach(() => sandbox.rollback()); // …that is rolled back afterwards

test("registers a user and hashes the password", async () => {
  const result = await register({ email: "ada@example.com", name: "Ada", password: "correct horse battery" });

  expect(result.errors).toBeUndefined();
  const user = await repo.getByOrThrow(User, { email: "ada@example.com" });
  expect(user.passwordHash).toMatch(/^[0-9a-f]{32}:[0-9a-f]{64}$/);
});

test("reports a taken email on the field", async () => {
  const params = { email: "ada@example.com", name: "Ada", password: "correct horse battery" };
  await register(params);

  expect(await register(params)).toEqual({ errors: { email: ["has already been taken"] } });
});

Test data is plain functions, in a file the tests share:

ts
// test/factories.ts: test data as plain functions. insertOrThrow makes a broken fixture
// fail loudly.
import { change } from "egon-orm";
import { repo } from "../src/db.js";
import { Post, User } from "../src/schemas.js";

export function insertUser(name: string) {
  return repo.insertOrThrow(
    change(User, { email: `${name.toLowerCase()}@example.com`, name, passwordHash: "x" }),
  );
}

export function insertPost(author: User, title: string, publishedAt: Date | null = new Date()) {
  return repo.insertOrThrow(change(Post, { authorId: author.id, title, body: `${title}.`, publishedAt }));
}

The tests of the feed and the moderation form are a second test file, with the same sandbox setup. expectQueries returns the statements a piece of code ran, so the first test can pin down that the feed does not run one query per post; the second sends the nested form, with an update, an insert and a delete in one call:

ts
// test/posts.itest.ts: every test file sets up its own sandbox, as accounts.itest.ts does.
import { change } from "egon-orm";
import { createSandbox, expectQueries } from "egon-orm/testing";
import { afterEach, beforeEach, expect, test } from "vitest";
import { moderate } from "../src/comments.js";
import { repo } from "../src/db.js";
import { feed } from "../src/posts.js";
import { Comment } from "../src/schemas.js";
import { insertPost, insertUser } from "./factories.js";

const sandbox = await createSandbox(repo);
beforeEach(() => sandbox.begin());
afterEach(() => sandbox.rollback());

test("the feed loads a page of posts and their authors in two queries", async () => {
  const ada = await insertUser("Ada");
  const bo = await insertUser("Bo");
  for (const title of ["One", "Two", "Three"]) await insertPost(ada, title);
  await insertPost(bo, "Four");
  await insertPost(bo, "Draft", null); // not published: not in the feed

  const queries = await expectQueries(repo, async () => {
    const page = await feed(1, 3);
    expect(page.map((p) => p.author.name)).toHaveLength(3);
  });
  expect(queries).toHaveLength(2); // the posts, then their authors: never one per post
});

test("moderation edits a comment, deletes one and adds one, in one transaction", async () => {
  const ada = await insertUser("Ada");
  const post = await insertPost(ada, "Hello");
  const keep = await repo.insertOrThrow(
    change(Comment, { postId: post.id, authorName: "Bo", body: "Fisrt!" }),
  );
  await repo.insertOrThrow(change(Comment, { postId: post.id, authorName: "Spam", body: "Buy now" }));

  const result = await moderate(post.id, {
    title: "Hello, world",
    comments: [
      { id: keep.id, authorName: "Bo", body: "First!" }, // matched by id: an update
      { authorName: "Ada", body: "Thanks, Bo." }, //       no id: an insert
    ], //                                                  the spam is left out: deleted (onReplace)
  });

  expect(result.success).toBe(true);
  const comments = await repo.all(Comment);
  expect(comments.map((c) => c.body).sort()).toEqual(["First!", "Thanks, Bo."]);
});

And publishing, in a third file. Its last test checks the batch as data, without running it:

ts
// test/publishing.itest.ts
import { Batch, change } from "egon-orm";
import { createSandbox } from "egon-orm/testing";
import { afterEach, beforeEach, expect, test } from "vitest";
import { repo } from "../src/db.js";
import { publish, publishBatch } from "../src/publishing.js";
import { Notification, Post, Subscription, User } from "../src/schemas.js";

// Every test file sets up its own sandbox.
const sandbox = await createSandbox(repo);
beforeEach(() => sandbox.begin());
afterEach(() => sandbox.rollback());

// An author, two readers who follow her, and a draft of hers.
async function setUp() {
  const user = (name: string) =>
    repo.insertOrThrow(change(User, { email: `${name.toLowerCase()}@example.com`, name, passwordHash: "x" }));
  const ada = await user("Ada");
  const bo = await user("Bo");
  const cy = await user("Cy");
  await repo.insertAll(Subscription, [
    { readerId: bo.id, authorId: ada.id },
    { readerId: cy.id, authorId: ada.id },
  ]);
  const draft = await repo.insertOrThrow(change(Post, { authorId: ada.id, title: "Hello", body: "Hi." }));
  return { bo, cy, draft };
}

test("publishing sets publishedAt and notifies every reader of the author", async () => {
  const { bo, cy, draft } = await setUp();

  const result = await publish(draft.id);

  expect(result).toMatchObject({ notified: 2 });
  expect((await repo.getOrThrow(Post, draft.id)).publishedAt).toBeInstanceOf(Date);
  const notified = (await repo.all(Notification)).map((n) => n.userId).sort();
  expect(notified).toEqual([bo.id, cy.id].sort());
});

test("publishing twice fails at the guard and writes nothing more", async () => {
  const { draft } = await setUp();
  await publish(draft.id);

  // `draft` still says "not published": the batch reads the post again, so it is not fooled.
  expect(await publish(draft.id)).toEqual({
    published: false,
    failedAt: "unpublished",
    error: "already published",
  });
  expect(await repo.count(Notification)).toBe(2);
});

test("the batch is a value: its steps can be checked without running it", async () => {
  const { draft } = await setUp();

  const steps = Batch.toList(publishBatch(draft.id)).map((s) => `${s.name}:${s.operation}`);

  expect(steps).toEqual([
    "post:one",
    "unpublished:run",
    "published:update",
    "readers:all",
    "notifications:insertAll",
  ]);
});

The repository's blog has a few more tests (test/pages.itest.ts: creating a post, the post page, the statistics, an invalid comment), written the same way.

Running the blog from the repository ​

The finished blog is in the repository, with its tests. They use the Postgres that the repository's Docker Compose file starts (on port 54329), and give each test file a database of its own:

sh
pnpm i
pnpm db:up
pnpm --filter @egon-orm/example-blog test

What you used ​

PartWhat it did hereGuide
SchemaUser, Post, Comment: fields, associations, a getter, changeset functionsSchemas
Migrationthe tables, keys and constraint names, written with egon migrate create and applied with egon migrateMigrations, CLI
Changesetsign-up and post forms: permitted fields, Zod, unique emailChangesets, Validation
Querythe feed, the post page, statisticsQueries
Preloadauthors and comments, without N+1Associations
Nested changesetthe moderation formNested changesets
Batchpublish and notifyBatch
Sandboxtests without cleanupTesting

Good to know

  • Rows are data. There is no post.save(): you describe a change with a changeset and give it to the repo. A row in memory is a copy of the database row as it was read.
  • Writes return a result. Check result.success, or use the OrThrow twin.
  • Nothing is loaded lazily. Preload what a page needs; the types tell you when you forgot.
  • Transactions are explicit. Only a batch, repo.transaction, or a write that spans several statements (the moderation form) opens one.

See also ​