Chapter 11: Events and Mail
Everything so far finished inside the request: validate, write a row, redirect. This chapter is about the work that should not. When Bob comments on Ada's post, Ada gets an email, and Bob's browser must not wait for a mail server to answer before it sees the page.
Chapter 11: Events and Mail
Everything so far finished inside the request: validate, write a row, redirect. This chapter is about the work that should not. When Bob comments on Ada's post, Ada gets an email, and Bob's browser must not wait for a mail server to answer before it sees the page.
Four names for that one sentence, and the chapter is mostly about why there are four:
| Piece | Answers |
|---|---|
| Event | Something happened. The controller announces it and stops caring. |
| Listener | Somebody cares. It decides what to do about the announcement. |
| Job | Work that outlives the request. A payload on a queue, run by whoever picks it up. |
| The message itself: a subject, a body, a recipient. |
The request stops at the first box; everything after it is work the reader never waits for:
flowchart LR
Controller["CommentController<br/>emit(new CommentPosted)"]
Listener["SendCommentMailListener<br/>dispatches the job"]
Job["SendCommentMailJob<br/>payload: { commentId }"]
Mail["NewCommentMail<br/>to the post's author"]
Controller --> Listener --> Job --> Mail
What you'll learn:
- Where each of the four is registered, and the one registration nothing checks for you
- Why a job payload is ids rather than records
- What
QUEUE_CONNECTION=syncreally does, and what changes when it stops being sync - Three fakes for three different seams, and why one of them cannot be a container binding
Start the dev server if it is not running:
bun run dev
1. Three layers, three commands
bunx guren add events
bunx guren add queue
bunx guren add mail
Each one wrote a sample of its kind plus a provider, and registered both the framework's service provider and yours in src/app.ts. Open it: the providers array has been rewritten onto a single line with six new entries at the end, a framework provider and an app provider for each command. That collapsing is the patcher's doing, not yours, and it is the shape every add command leaves behind.
The three providers are worth reading, because two of them are files you are about to edit:
app/Providers/EventProvider.tsconnects an event class to a listener object. That connection is a line of code, not a convention: nothing scansapp/Listeners/looking for work.app/Providers/QueueProvider.tsbuilds the queue manager and callsregisterJob()for each job class. Note the driver line:QUEUE_CONNECTION=syncruns a dispatched job inline, in the dispatching process;memoryputs it in a queue a worker drains. Your.envalready sayssync.app/Providers/MailProvider.tsbuilds the mail manager.MAIL_MAILER=log, also already in your.env, prints outgoing mail to the server output instead of sending it. Nothing to sign up for, and nothing to accidentally deliver.
The samples (OrderPlaced, SendOrderReceiptListener, ProcessWelcomeSequenceJob, WelcomeEmailMail) exist so you can see the shape of each file. You will replace all four in section 3.
2. Specify the mail
Three tests, and each one is fake-shaped differently on purpose. Read the setup before the assertions:
tests/CommentMail.test.tsimport { beforeAll, beforeEach, describe, it } from 'bun:test'
import { MailManager, getQueueDriver, setQueueDriver } from '@guren/core'
import { TestApp, fakeMail, fakeQueue } from '@guren/testing'
import app from '../src/app.js'
import { resetDatabase } from '../config/database.js'
import { Post, type PostRecord } from '../app/Models/Post.js'
import { User, type UserRecord } from '../app/Models/User.js'
import { SendCommentMailJob, type SendCommentMailPayload } from '../app/Jobs/SendCommentMailJob.js'
const mail = fakeMail()
describe('comment mail', () => {
let http: TestApp
let ada: UserRecord
let bob: UserRecord
let post: PostRecord
let asBob: TestApp
beforeAll(async () => {
http = await TestApp.fromApp(app)
// Mail.send() asks the manager for a transport, so the fake goes inside a
// real manager rather than in place of one.
const manager = new MailManager({ default: 'fake', from: { email: 'blog@example.com', name: 'Blog' } })
manager.registerTransport('fake', () => mail.getTransport())
app.container.fake('mail', manager)
})
beforeEach(async () => {
await resetDatabase()
mail.clear()
ada = await User.create({ name: 'Ada', email: 'ada@example.com', password: 'correct horse battery' })
bob = await User.create({ name: 'Bob', email: 'bob@example.com', password: 'correct horse battery' })
post = await Post.forceCreate({ title: 'Relativity', body: 'A body', authorId: ada.id })
asBob = await http.actingAs(bob).withCsrf()
})
it('mails the post author when someone else comments', async () => {
await asBob.post(`/posts/${post.id}/comments`, { body: 'Nice post' }).assertRedirect(`/posts/${post.id}`)
mail.assertSentTo('ada@example.com')
mail.assertSentWithSubject('New comment on Relativity')
mail.assertSentWithBodyContaining('Bob')
})
it('does not mail you about your own comment', async () => {
const asAda = await http.actingAs(ada).withCsrf()
await asAda.post(`/posts/${post.id}/comments`, { body: 'A note to myself' }).assertRedirect(`/posts/${post.id}`)
mail.assertNothingSent()
})
it('hands the mail to the queue instead of sending it in the request', async () => {
const queue = fakeQueue()
// Job.dispatch() reads a module-level driver, not the container, so this
// seam is a setter and not a container fake. Put the real one back after.
const real = getQueueDriver()
setQueueDriver(queue.getDriver())
try {
await asBob.post(`/posts/${post.id}/comments`, { body: 'Nice post' }).assertRedirect(`/posts/${post.id}`)
queue.assertPushed<SendCommentMailPayload>(SendCommentMailJob, (payload) => payload.commentId > 0)
mail.assertNothingSent()
} finally {
if (real) setQueueDriver(real)
}
})
})
assertPushed is given its payload type explicitly. Job.dispatch is a generic static, so a job class on its own does not tell TypeScript what its payload is, and an inferred unknown makes the predicate fail to compile.
The third test is the one that describes the design rather than the feature. With a fake queue driver in place the job is recorded and never run, so no mail goes out. If that test ever passes and mail is sent, the controller is doing the work itself.
bun test
Red on the import: there is no SendCommentMailJob yet.
3. The four pieces, by hand
An event carries the smallest thing that identifies what happened:
app/Events/CommentPosted.tsimport { Event } from '@guren/core'
export class CommentPosted extends Event {
static override eventName = 'CommentPosted'
constructor(public readonly commentId: number) {
super()
}
}
The listener decides what happening means. This one does no work itself; it hands the work to a queue and returns:
app/Listeners/SendCommentMailListener.tsimport { Listener } from '@guren/core'
import { CommentPosted } from '../Events/CommentPosted.js'
import { SendCommentMailJob } from '../Jobs/SendCommentMailJob.js'
export class SendCommentMailListener extends Listener<CommentPosted> {
static override event = CommentPosted
async handle(event: CommentPosted): Promise<void> {
await SendCommentMailJob.dispatch({ commentId: event.commentId })
}
}
The job is the piece that may run in another process, minutes later:
app/Jobs/SendCommentMailJob.tsimport { Job } from '@guren/core'
import { Comment } from '../Models/Comment.js'
import { User } from '../Models/User.js'
import { NewCommentMail } from '../Mail/NewCommentMail.js'
/** A queued payload is JSON on its way to another process: ids, never records. */
export interface SendCommentMailPayload {
commentId: number
}
export class SendCommentMailJob extends Job<SendCommentMailPayload> {
static override queue = 'default'
static override maxAttempts = 3
async handle(payload: SendCommentMailPayload): Promise<void> {
const comment = await Comment.findWith(payload.commentId, ['post', 'author'])
if (!comment?.post || !comment.author) return
const postAuthor = await User.find(comment.post.authorId)
if (!postAuthor || postAuthor.id === comment.authorId) return
await new NewCommentMail(this.make('mail'), {
postTitle: comment.post.title,
commenter: comment.author.name,
body: comment.body,
url: `/posts/${comment.post.id}`,
})
.to(postAuthor.email)
.send()
}
}
Two decisions in that file are the chapter's real content. The payload is a commentId, not the comment: by the time this runs, the row may have changed, and a record cannot be serialised onto a queue anyway. And "do not mail me about my own comment" lives here, next to the send, rather than in the controller. The controller announces what happened; it does not decide who deserves an email about it.
The mail is the message and nothing else:
app/Mail/NewCommentMail.tsimport { Mail, type MailManager } from '@guren/core'
export interface NewCommentMailData {
postTitle: string
commenter: string
body: string
url: string
}
export class NewCommentMail extends Mail {
constructor(
manager: MailManager,
private readonly data: NewCommentMailData,
) {
super(manager)
}
build(): this {
return this.subject(`New comment on ${this.data.postTitle}`).text(
`${this.data.commenter} wrote:\n\n${this.data.body}\n\nRead it: ${this.data.url}`,
)
}
}
You never call build(). send() calls it once, then checks that the message has a recipient, a subject and a body, and hands it to the transport.
Now the two registrations. The event provider is where a class becomes a subscription:
app/Providers/EventProvider.tsimport { ServiceProvider, type EventManager } from '@guren/core'
import { CommentPosted } from '../Events/CommentPosted.js'
import { SendCommentMailListener } from '../Listeners/SendCommentMailListener.js'
export default class EventProvider extends ServiceProvider {
register(): void {}
boot(): void {
const events = this.container.make<EventManager>('events')
const listener = new SendCommentMailListener()
events.on(CommentPosted, (event) => listener.handle(event), {
priority: SendCommentMailListener.priority,
})
}
}
Read the wiring closely, because it explains a class of bug you will otherwise meet later. The provider passes priority and calls handle. That is all it reads. The Listener base class also declares shouldQueue, queue and an optional shouldHandle(), and this wiring honours none of them: a listener that sets shouldQueue = true and expects the framework to queue it will be run inline, silently. Whatever the class declares, the truth is the line in this file.
The queue provider is where a job class becomes dispatchable:
app/Providers/QueueProvider.tsimport { ServiceProvider, MemoryDriver, SyncDriver, createQueueManager, registerJob, type QueueManager } from '@guren/core'
import { SendCommentMailJob } from '../Jobs/SendCommentMailJob.js'
export default class QueueProvider extends ServiceProvider {
register(): void {
const queue = createQueueManager({
// QUEUE_CONNECTION=sync executes jobs inline on dispatch (default,
// no worker process needed); 'memory' queues them for a Worker.
default: process.env.QUEUE_CONNECTION === 'memory' ? 'memory' : 'sync',
drivers: {
sync: () => new SyncDriver(),
memory: () => new MemoryDriver(),
},
})
this.container.instance('queue', queue)
}
boot(): void {
// A queued message carries the job's name, so the driver can only run a job
// the registry knows. Nothing in `guren check` looks for a missing one.
registerJob(SendCommentMailJob)
const queue = this.container.make<QueueManager>('queue')
queue.driver()
}
}
Finally the controller announces:
app/Http/Controllers/CommentController.tsimport { Controller } from '@guren/core'
import { Post } from '../../Models/Post.js'
import { Comment } from '../../Models/Comment.js'
import type { UserRecord } from '../../Models/User.js'
import { CommentPosted } from '../../Events/CommentPosted.js'
import { CommentPayloadSchema } from '../Validators/CommentValidator.js'
export default class CommentController extends Controller {
async store(): Promise<Response> {
const post = this.model(Post)
await this.authorize('create', Comment)
const author = await this.auth.userOrFail<UserRecord>()
const data = await this.validateBody(CommentPayloadSchema)
const comment = await Comment.forceCreate({ ...data, postId: post.id, authorId: author.id })
await this.make('events').emit(new CommentPosted(comment.id))
return this.redirect(`/posts/${post.id}`)
}
async destroy(): Promise<Response> {
const comment = this.model(Comment)
await this.authorize('delete', [Comment, comment])
await Comment.delete({ id: comment.id })
return this.redirect(`/posts/${comment.postId}`)
}
}
emit is awaited, and it awaits every listener in priority order. Under sync that means the whole chain, job included, finishes before the redirect is returned. That is worth being clear-eyed about: sync does not make the work asynchronous, it makes the code asynchronous-shaped. When you move to a worker, the controller does not change.
The four samples the blueprints installed have no owner now:
rm app/Events/OrderPlaced.ts app/Listeners/SendOrderReceiptListener.ts app/Jobs/ProcessWelcomeSequenceJob.ts app/Mail/WelcomeEmailMail.ts
bun test
Green.
Checkpoint: comment on someone else's post in the browser and look at the terminal running bun run dev:
[mail] ------------------------------------------------------------
[mail] To: ada@example.com
[mail] From: noreply@example.com
[mail] Subject: New comment on Relativity
[mail] Bob wrote:
[mail]
[mail] Nice post
[mail]
[mail] Read it: /posts/1
[mail] ------------------------------------------------------------
That is the log transport. Point MAIL_MAILER at a real one and the same message leaves the building.
bunx guren gate
git add -A
git commit -m "feat: mail the post author when someone comments"
4. The registration nothing checks
Run the integrity check and read it for what is not there:
bunx guren check
It has an opinion about your routes, your pages, your schema, your attachments. It has none about app/Jobs/. A job class that never reaches registerJob() looks perfect: it compiles, it lints, its tests pass if they fake the queue. It fails the first time something dispatches it for real, with a message that at least names the problem:
SyncDriver: job class "SendCommentMailJob" is not registered. Call registerJob() with the class whose jobName (or class name) is "SendCommentMailJob".
This is exactly the situation chapter 8 was about: a project invariant the framework cannot see. So write it down where the agent reads it.
.claude/rules/background-work.md---
description: Events, listeners and jobs — every job is registered, every listener is wired, and the payload is ids
globs:
- "app/Events/**"
- "app/Listeners/**"
- "app/Jobs/**"
- "app/Mail/**"
- "app/Providers/EventProvider.ts"
- "app/Providers/QueueProvider.ts"
---
# Background work
1. **Every `Job` subclass is registered.** Add `registerJob(TheJob)` to `boot()` in `app/Providers/QueueProvider.ts` in the same change that adds the class. A queued message carries the job's name and the driver resolves it through that registry; an unregistered job throws at dispatch time and `guren check` says nothing about it.
2. **Every listener is wired.** A class in `app/Listeners/` runs only because `app/Providers/EventProvider.ts` calls `events.on(TheEvent, (event) => listener.handle(event), …)`. `shouldQueue`, `queue` and `shouldHandle()` on the class are inert unless that wiring reads them, so do not rely on them: to queue work, dispatch a job from `handle`.
3. **A job payload is JSON: ids, never records.** The job may run in another process, after the row has changed. Load what you need inside `handle`, and return early when the record is gone.
4. **Controllers announce, listeners decide.** A controller emits an event and returns. Rules about who gets mail (skip the actor, skip duplicates) live in the job or the listener, not in the action.
5. **Test the seam, not the plumbing.** Mail is faked by registering a `fakeMail()` transport on a real `MailManager` and binding that with `app.container.fake('mail', manager)`. The queue is faked with `setQueueDriver(fakeQueue().getDriver())`, never through the container, because `Job.dispatch()` reads a module-level driver.
The PostToolUse hook runs guren check --arch after every edit, and check will keep quiet about all five of these. The rule is the check.
git add -A
git commit -m "docs: add a background-work rule for the agent"
5. Specify the announcement
Publishing a post should tell everyone who took the trouble to comment on it. Same four pieces, one shape harder: a fan-out with a de-duplication rule.
tests/PostPublishedMail.test.tsimport { beforeAll, beforeEach, describe, it } from 'bun:test'
import { MailManager } from '@guren/core'
import { TestApp, fakeMail } from '@guren/testing'
import app from '../src/app.js'
import { resetDatabase } from '../config/database.js'
import { Post, type PostRecord } from '../app/Models/Post.js'
import { Comment } from '../app/Models/Comment.js'
import { User, type UserRecord } from '../app/Models/User.js'
const mail = fakeMail()
describe('publishing a post', () => {
let http: TestApp
let ada: UserRecord
let post: PostRecord
let asAda: TestApp
beforeAll(async () => {
http = await TestApp.fromApp(app)
const manager = new MailManager({ default: 'fake', from: { email: 'blog@example.com', name: 'Blog' } })
manager.registerTransport('fake', () => mail.getTransport())
app.container.fake('mail', manager)
})
beforeEach(async () => {
await resetDatabase()
mail.clear()
ada = await User.create({ name: 'Ada', email: 'ada@example.com', password: 'correct horse battery' })
post = await Post.forceCreate({ title: 'Relativity', body: 'A body', authorId: ada.id })
asAda = await http.actingAs(ada).withCsrf()
})
it('mails everyone who commented, once each', async () => {
const bob = await User.create({ name: 'Bob', email: 'bob@example.com', password: 'correct horse battery' })
const cleo = await User.create({ name: 'Cleo', email: 'cleo@example.com', password: 'correct horse battery' })
await Comment.forceCreate({ body: 'First', postId: post.id, authorId: bob.id })
await Comment.forceCreate({ body: 'Second', postId: post.id, authorId: bob.id })
await Comment.forceCreate({ body: 'Third', postId: post.id, authorId: cleo.id })
await asAda.post(`/posts/${post.id}/publish`).assertRedirect(`/posts/${post.id}`)
mail.assertSentTo('bob@example.com')
mail.assertSentTo('cleo@example.com')
mail.assertSentWithSubject('Relativity is published')
mail.assertSentTimes(2)
})
it('does not mail the author their own post', async () => {
await Comment.forceCreate({ body: 'A note to myself', postId: post.id, authorId: ada.id })
await asAda.post(`/posts/${post.id}/publish`).assertRedirect(`/posts/${post.id}`)
mail.assertNothingSent()
})
})
assertSentTimes(2) is the whole point of the first test. Bob commented twice; Bob gets one email.
bun test
Two red.
6. Delegate it
When a post is published, mail everyone who commented on it. Emit a
PostPublishedevent frompublishinPostController, wire a listener inEventProviderthat dispatches aNotifyCommentersJob, and send aPostPublishedMailto each distinct commenter, skipping the post's author.tests/PostPublishedMail.test.tsdescribes it; make it pass.
The prompt does not mention registerJob, and it does not need to: the rule you wrote in section 4 is scoped to app/Jobs/** and app/Providers/QueueProvider.ts, so the agent reads it before it writes either. That is the whole experiment. Check the diff for the registration line before you check anything else.
No agent handy? The event carries the post:
app/Events/PostPublished.tsimport { Event } from '@guren/core'
export class PostPublished extends Event {
static override eventName = 'PostPublished'
constructor(public readonly postId: number) {
super()
}
}
app/Listeners/NotifyCommentersListener.tsimport { Listener } from '@guren/core'
import { PostPublished } from '../Events/PostPublished.js'
import { NotifyCommentersJob } from '../Jobs/NotifyCommentersJob.js'
export class NotifyCommentersListener extends Listener<PostPublished> {
static override event = PostPublished
async handle(event: PostPublished): Promise<void> {
await NotifyCommentersJob.dispatch({ postId: event.postId })
}
}
app/Mail/PostPublishedMail.tsimport { Mail, type MailManager } from '@guren/core'
export interface PostPublishedMailData {
postTitle: string
url: string
}
export class PostPublishedMail extends Mail {
constructor(
manager: MailManager,
private readonly data: PostPublishedMailData,
) {
super(manager)
}
build(): this {
return this.subject(`${this.data.postTitle} is published`).text(
`A post you commented on is now published.\n\nRead it: ${this.data.url}`,
)
}
}
app/Jobs/NotifyCommentersJob.tsimport { Job } from '@guren/core'
import { Post } from '../Models/Post.js'
import { Comment } from '../Models/Comment.js'
import { User } from '../Models/User.js'
import { PostPublishedMail } from '../Mail/PostPublishedMail.js'
export interface NotifyCommentersPayload {
postId: number
}
export class NotifyCommentersJob extends Job<NotifyCommentersPayload> {
static override queue = 'default'
static override maxAttempts = 3
async handle(payload: NotifyCommentersPayload): Promise<void> {
const post = await Post.find(payload.postId)
if (!post) return
const comments = await Comment.where('postId', post.id).get()
const recipientIds = [...new Set(comments.map((comment) => comment.authorId))].filter(
(id) => id !== post.authorId,
)
if (recipientIds.length === 0) return
const recipients = await User.where({ id: recipientIds }).get()
const manager = this.make('mail')
for (const recipient of recipients) {
await new PostPublishedMail(manager, {
postTitle: post.title,
url: `/posts/${post.id}`,
})
.to(recipient.email)
.send()
}
}
}
app/Providers/EventProvider.tsimport { ServiceProvider, type EventManager } from '@guren/core'
import { CommentPosted } from '../Events/CommentPosted.js'
import { PostPublished } from '../Events/PostPublished.js'
import { SendCommentMailListener } from '../Listeners/SendCommentMailListener.js'
import { NotifyCommentersListener } from '../Listeners/NotifyCommentersListener.js'
export default class EventProvider extends ServiceProvider {
register(): void {}
boot(): void {
const events = this.container.make<EventManager>('events')
const commentListener = new SendCommentMailListener()
const publishListener = new NotifyCommentersListener()
events.on(CommentPosted, (event) => commentListener.handle(event), {
priority: SendCommentMailListener.priority,
})
events.on(PostPublished, (event) => publishListener.handle(event), {
priority: NotifyCommentersListener.priority,
})
}
}
app/Providers/QueueProvider.tsimport { ServiceProvider, MemoryDriver, SyncDriver, createQueueManager, registerJob, type QueueManager } from '@guren/core'
import { SendCommentMailJob } from '../Jobs/SendCommentMailJob.js'
import { NotifyCommentersJob } from '../Jobs/NotifyCommentersJob.js'
export default class QueueProvider extends ServiceProvider {
register(): void {
const queue = createQueueManager({
// QUEUE_CONNECTION=sync executes jobs inline on dispatch (default,
// no worker process needed); 'memory' queues them for a Worker.
default: process.env.QUEUE_CONNECTION === 'memory' ? 'memory' : 'sync',
drivers: {
sync: () => new SyncDriver(),
memory: () => new MemoryDriver(),
},
})
this.container.instance('queue', queue)
}
boot(): void {
// A queued message carries the job's name, so the driver can only run a job
// the registry knows. Nothing in `guren check` looks for a missing one.
registerJob(SendCommentMailJob)
registerJob(NotifyCommentersJob)
const queue = this.container.make<QueueManager>('queue')
queue.driver()
}
}
And the publish action announces:
app/Http/Controllers/PostController.tsimport { Controller, ValidationException, paginate, type PaginatedPageProps } from '@guren/core'
import { pages } from '@/.guren/pages.gen'
import { Post } from '../../Models/Post.js'
import { Comment } from '../../Models/Comment.js'
import { Tag } from '../../Models/Tag.js'
import { PostTag } from '../../Models/PostTag.js'
import type { UserRecord } from '../../Models/User.js'
import { PostPublished } from '../../Events/PostPublished.js'
import { PostResource, type PostResourceData } from '../Resources/PostResource.js'
import { CommentResource } from '../Resources/CommentResource.js'
import { ListPostsQuerySchema, PostIdParamSchema, PostImageParamSchema, PostPayloadSchema } from '../Validators/PostValidator.js'
type PostsIndexProps = PaginatedPageProps<PostResourceData>
async function syncTags(postId: number, names: string[]): Promise<void> {
await PostTag.delete({ postId })
for (const name of names) {
const tag = (await Tag.first({ name })) ?? (await Tag.create({ name }))
await PostTag.forceCreate({ postId, tagId: tag.id })
}
}
export default class PostController extends Controller {
async index(): Promise<Response> {
const { page } = this.validateQuery(ListPostsQuerySchema)
const result = await Post.withPaginate('author', { page, perPage: 10, orderBy: ['id', 'desc'] })
const paginator = paginate(result, { path: this.request.path ?? '/posts' })
return this.inertia(pages.posts.Index, {
data: result.data.map((post) => new PostResource(post).toJSON()),
pagination: {
meta: paginator.meta(),
links: paginator.links(),
},
} satisfies PostsIndexProps)
}
async show(): Promise<Response> {
const { id } = this.validateParams(PostIdParamSchema)
const post = await Post.findWithOrFail(id, ['author', 'tags'])
const [withFiles] = await Post.withAttachments([post], ['cover', 'images'])
const comments = await Comment.where('postId', post.id).with('author').orderBy('id', 'asc').get()
return this.inertia(pages.posts.Show, {
post: new PostResource(withFiles!).toJSON(),
canManage: await this.can('update', [Post, post]),
comments: await Promise.all(
comments.map(async (comment) => ({
...new CommentResource(comment).toJSON(),
canDelete: await this.can('delete', [Comment, comment]),
})),
),
})
}
async create(): Promise<Response> {
return this.inertia(pages.posts.New, {})
}
async store(): Promise<Response> {
const author = await this.auth.userOrFail<UserRecord>()
const { tags, ...data } = await this.validateBody(PostPayloadSchema)
const post = await Post.forceCreate({ ...data, authorId: author.id })
await syncTags(post.id, tags)
const cover = await this.file('cover')
if (cover) {
await Post.attach(post.id, 'cover', cover)
}
for (const file of await this.files('images')) {
await Post.attach(post.id, 'images', file)
}
return this.redirect(`/posts/${post.id}`)
}
async edit(): Promise<Response> {
const post = this.model(Post)
await this.authorize('update', [Post, post])
const withTags = await Post.findWithOrFail(post.id, 'tags')
return this.inertia(pages.posts.Edit, {
post: new PostResource(withTags).toJSON(),
})
}
async update(): Promise<Response> {
const post = this.model(Post)
await this.authorize('update', [Post, post])
const { tags, ...data } = await this.validateBody(PostPayloadSchema)
await Post.update({ id: post.id }, data)
await syncTags(post.id, tags)
return this.redirect(`/posts/${post.id}`)
}
async cover(): Promise<Response> {
const post = this.model(Post)
await this.authorize('update', [Post, post])
const cover = await this.file('cover')
if (!cover) {
throw new ValidationException({ cover: ['Choose an image.'] })
}
await Post.attach(post.id, 'cover', cover)
return this.redirect(`/posts/${post.id}`)
}
async destroyImage(): Promise<Response> {
const post = this.model(Post)
await this.authorize('update', [Post, post])
const { attachment } = this.validateParams(PostImageParamSchema)
await Post.detach(post.id, 'images', attachment)
return this.redirect(`/posts/${post.id}`)
}
async destroy(): Promise<Response> {
const post = this.model(Post)
await this.authorize('delete', [Post, post])
await Post.purgeAttachments(post.id)
await Post.delete({ id: post.id })
return this.redirect('/posts')
}
async publish(): Promise<Response> {
const post = this.model(Post)
await this.authorize('publish', [Post, post])
await Post.forceUpdate({ id: post.id }, { publishedAt: new Date().toISOString() })
await this.make('events').emit(new PostPublished(post.id))
return this.redirect(`/posts/${post.id}`)
}
async unpublish(): Promise<Response> {
const post = this.model(Post)
await this.authorize('publish', [Post, post])
await Post.forceUpdate({ id: post.id }, { publishedAt: null })
return this.redirect(`/posts/${post.id}`)
}
}
bun test
The rubric:
registerJob(NotifyCommentersJob)is inQueueProvider.boot(), andevents.on(PostPublished, …)is inEventProvider.boot(). Without both, the feature is dead code that compiles.- The payload is
{ postId }. Recipients are resolved insidehandle, not passed in. - Commenters are de-duplicated by author id and the post's author is removed from the list, in the job. Two comments from Bob are one email to Bob.
publishemits and returns. It does not query comments and it does not know that mail exists.- Both new tests and the three from section 2 are green.
Checkpoint: comment on a draft from two accounts, publish it, and watch two [mail] blocks and no third one for yourself.
bunx guren gate
git add -A
git commit -m "feat: mail commenters when a post is published"
Where you are
- An event, a listener, a job and a mail, each registered in a place you can point at.
- A controller that announces and returns, and business rules about recipients that live next to the sending.
- Three test seams: a mail transport inside a real manager, a queue driver set globally, and a request that proves the two are connected.
- A project rule carrying the one invariant
guren checkhas no opinion about, and an agent that followed it.
Common trip-ups
SyncDriver: job class "X" is not registered.registerJob(X)is missing fromQueueProvider.boot(). This is the error the rule in section 4 exists to prevent.Email must have at least one recipient(or subject, or body).send()validates the built message. Ato()that receivedundefined, or abuild()that returns before setting the subject, both land here.- Nothing arrives and no error appears. Check the listener is wired in
EventProvider.boot(). An event with no listeners is a successfulemit. container.fake('queue', …)changes nothing in a test.Job.dispatch()resolves its driver from a module-level setter, not the container. UsesetQueueDriver(), and put the previous driver back.- A test faking
mailwithfakeMail()directly throws.Mail.send()callsmanager.transport(name), and the fake is a transport, not a manager. Register it on a realMailManagerand bind that. - The mail is sent during the request even though there is a queue. That is
QUEUE_CONNECTION=syncworking as designed. Set it tomemoryand runbunx guren queue:workto watch a worker drain the queue instead.
Exercises
- Set
QUEUE_CONNECTION=memoryin.env, restart the server, and post a comment. No mail appears. Now runbunx guren queue:work --oncein a second terminal: still nothing. Explain why, then put the value back. The answer is the reasonmemoryis a development driver and not a deployment one. - Register a second listener on
CommentPostedwith a higherprioritythat only logs. Which one runs first? Now make the first one throw, and say what happens to the second and to the request.
Next
Chapter 12: Your App as an Agent's Tool turns the routes you already have into tools an agent can call, and shows the same authorization gap from chapter 7 becoming a hard failure instead of a passing audit.