Skip to content

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:

ts
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 ​

OrderCallbackRunsabort()
Saving
1beforeValidationBefore the validators, on every save, and on isValid()Stops the save
2afterValidationAfter the validatorsStops the save
3beforeSaveBefore every write, new or persistedStops the save
4beforeCreateBefore inserting a new recordStops the save
4beforeUpdateBefore updating a persisted recordStops the save
The write to D1Skipped when a persisted record has no changes
5afterCreateAfter inserting a new recordToo late: AbortAfterPersistence
5afterUpdateAfter updating a persisted recordToo late: AbortAfterPersistence
6afterSaveAfter every successful saveToo late: AbortAfterPersistence
Destroying
1beforeDestroyBefore the dependent strategies and the DELETEStops the destroy
The delete in D1With the dependents, in one batch
2afterDestroyAfter the record is destroyedToo 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:

ts
import { abort } from "@forestfuture/d1-record";

this.beforeSave((order) => {
  if (order.suspended) throw abort("Orders can't change while suspended");
});
  • save() resolves to false and writes nothing. The record keeps its changes, and record.abortReason holds the reason.
  • saveOrThrow() throws RecordNotSaved with the reason.
  • destroy() resolves to false, and destroyOrThrow() throws RecordNotDestroyed.

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(); … }), and aroundCreate, aroundUpdate, and aroundDestroy.
  • afterInitialize and afterFind (#49): for every record built or loaded, and every record loaded.
  • record.touch() and afterTouch (#50): sets updatedAt without a full save.
  • on: "create" and on: "update" (#51): this.validates("password", { presence: true, on: "create" }). Until then, check record.isNewRecord() in an if option 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.