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.
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
| Work | Joins |
|---|---|
Writes through ctx.db | yes |
Nested CRUD through ctx.collections and ctx.globals | yes |
ctx.queue.<job>.publish() | yes |
ctx.channels.publish() | yes |
Email, fetch, ctx.realtime.notify(), a second db connection | no |
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.
.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.
Related
Typing a hook
An inline hook is typed from the collection above it. A handler in its own file has to declare what it takes, and which type that is depends on where the file sits.
Bulk writes
`updateMany` and `deleteMany` run the write hooks once per row and hand each call the shape of the whole batch, but a `before*` hook and an `after*` hook do not see the same set of rows.