QUESTPIE
SchemaHooks

Side effects

The `after*` write hooks run inside the transaction that made the write, so what you may safely do in one depends on whether the work can join that transaction or has to wait for the commit.

View markdown

An email sent from a hook that later rolls back is an email about a row that does not exist. QUESTPIE gives you one tool for each half of the problem. One is for work that belongs to the transaction. The other is for work that must not start until the commit lands.

Which hooks are inside

afterChange, afterDelete, beforePurge and afterPurge run inside the transaction that performed the write. Their ctx.db is that transaction, and throwing from one rolls the row back along with everything joined to it.

afterRead runs after the transaction has closed. So do onAfterCommit callbacks, which is the whole point of them.

What joins the transaction

WorkJoins
Writes through ctx.dbyes
Nested CRUD through ctx.collections and ctx.globalsyes
ctx.queue.<job>.publish()yes
ctx.channels.publish()yes
Email, fetch, ctx.realtime.notify(), a second db connectionno

A job published inside a transaction either goes to the broker in that transaction or lands in a dispatch table that drains after the commit. Either way a rolled-back write never dispatches it. Awaiting the publish directly is correct. Channels write their event to the same ledger transaction and wake subscribers afterwards.

What has to wait

ctx.onAfterCommit(cb) queues a callback on the outermost transaction. Nothing runs until that transaction commits.

src/questpie/server/collections/posts.ts
.hooks({
	afterChange: async ({ data, operation, queue, onAfterCommit }) => {
		if (operation !== "create") return;

		// Joins the transaction.
		await queue.indexPost.publish(
			{ postId: data.id },
			{ idempotencyKey: `index-post:${data.id}` },
		);

		// Cannot, so it waits.
		onAfterCommit(async () => {
			await fetch("https://hooks.example.com/post-created", {
				method: "POST",
				body: JSON.stringify({ id: data.id }),
			});
		});
	},
})

Inside a transaction the callbacks run in order once it commits, and the operation waits for them. Called outside any transaction, onAfterCommit runs the callback immediately without awaiting it.

Do not use it as a second chance to fail

A callback cannot roll anything back and cannot fail the request. Work that has to succeed belongs in a durable job, which retries.

The framework indexes search this way. Its own afterChange handler schedules the index write inside onAfterCommit, so a document is only ever indexed from data that survived.

At most once

onAfterCommit is at-most-once. Your callback runs once, or it never runs.

The callback lives only in the memory of the process that ran the write. Nothing records that it is owed. Two things lose it. It throws, and the error goes to the console with nothing to retry it. Or the process dies between the COMMIT and the callback finishing. That window is small, but it is not zero.

Neither case leaves a trace. A lost callback looks exactly like one that never fired. So the search indexing above is best effort too. Lose that callback and the row stays out of the index until the next write to it.

Use onAfterCommit for work you can afford to lose, or work a later pass can reconcile.

  • Hooks for the stages themselves.
  • Jobs for the queue that hooks publish to.
  • Channels for publishing a typed event from a hook.

On this page