Callbacks
Callbacks run your code at points in a record’s lifecycle, such as before it’s validated or after it’s saved.
Registering callbacks
Register callbacks in a Model’s static {} block. Each receives the record, and may be async:
import { abort, Model } from "@forestfuture/d1-record";
export class Order extends Model({
id: { type: "integer", null: false },
status: { type: "string", null: false, default: () => "pending" },
email: { type: "string", null: false },
totalCents: { type: "integer", null: false },
suspended: "boolean",
placedAt: "datetime",
}) {
static {
this.beforeValidation((order) => {
order.email = order.email.trim().toLowerCase();
});
this.beforeCreate((order) => {
order.placedAt = new Date();
});
this.beforeSave((order) => {
if (order.suspended) throw abort("Orders can't change while suspended");
});
this.afterSave(async (order) => {
if (order.hasSavedChangeTo("status", { to: "shipped" })) {
await notifyShipped(order.email);
}
});
}
}Callbacks of one kind run in the order declared. A subclass runs its parent’s callbacks, then its own.
TIP
Once a Model has been used, registering another callback throws InvalidModel, so request code can’t add one that leaks into later requests.
Callback order
| Order | Callback | Runs | abort() |
|---|---|---|---|
| Saving | |||
| 1 | beforeValidation | Before the validators, on every save, and on isValid() | Stops the save |
| 2 | afterValidation | After the validators | Stops the save |
| 3 | beforeSave | Before every write, new or persisted | Stops the save |
| 4 | beforeCreate | Before inserting a new record | Stops the save |
| 4 | beforeUpdate | Before updating a persisted record | Stops the save |
| The write to D1 | Skipped when a persisted record has no changes | ||
| 5 | afterCreate | After inserting a new record | Too late: AbortAfterPersistence |
| 5 | afterUpdate | After updating a persisted record | Too late: AbortAfterPersistence |
| 6 | afterSave | After every successful save | Too late: AbortAfterPersistence |
| Destroying | |||
| 1 | beforeDestroy | Before the dependent strategies and the DELETE | Stops the destroy |
| The delete in D1 | With the dependents, in one batch | ||
| 2 | afterDestroy | After the record is destroyed | Too late: AbortAfterPersistence |
A save runs either the create pair or the update pair, never both.
- After-callbacks run after every successful save, even one with no changes to write.
- A before-callback that changes an attribute makes the record dirty, and the change is written.
- An after-callback that changes an attribute leaves the record dirty. Nothing is written again.
Nothing is rolled back
D1 has committed a save’s writes before any after-callback runs. An error thrown by an after-callback reaches the caller, but the record stays saved.
Stopping a save
Throw abort(reason) from a before-callback to stop a save or destroy:
import { abort } from "@forestfuture/d1-record";
this.beforeSave((order) => {
if (order.suspended) throw abort("Orders can't change while suspended");
});save()resolves tofalseand writes nothing. The record keeps its changes, andrecord.abortReasonholds the reason.saveOrThrow()throwsRecordNotSavedwith the reason.destroy()resolves tofalse, anddestroyOrThrow()throwsRecordNotDestroyed.
Only abort() stops a save. Returning false does nothing, and any other error reaches the caller as it is.
TIP
In an after-callback, the write has already happened, so abort() throws AbortAfterPersistence, naming the callback.
Not built yet
These callbacks aren’t in d1-record yet. The code shows the shape they’re likely to take.
- Around callbacks (#48):
this.aroundSave(async (record, proceed) => { …; await proceed(); … }), andaroundCreate,aroundUpdate, andaroundDestroy. afterInitializeandafterFind(#49): for every record built or loaded, and every record loaded.record.touch()andafterTouch(#50): setsupdatedAtwithout a full save.on: "create"andon: "update"(#51):this.validates("password", { presence: true, on: "create" }). Until then, checkrecord.isNewRecord()in anifoption or a callback.
Compared with Rails
There’s no after_commit or after_rollback. In Rails, after_save runs before the save’s transaction commits. Here, D1 has already committed before any after-callback runs, so every after-callback is already “after commit”. In a batch, they run once the whole batch is written. When D1 refuses a write, nothing was written, and the save throws the constraint error.
Rails’ if: and unless: options for callbacks aren’t needed: write the condition inside the callback.