Skip to content

Batch ​

A batch is a list of named steps that run in one transaction. It is a value: you can build it in one place, add to it in another, inspect it in a test, and run it later.

ts
const signUp = createBatch()
  .insert("user", User.changeset(User, input))
  .insert("profile", ({ user }) => change(Profile, { userId: user.id }))
  .run("welcome", async (tx, { user }) =>
    (await queueWelcomeEmail(tx, user)) ? success(true) : failure("the queue is full"),
  );

const result = await repo.transaction(signUp);

if (result.success) {
  result.data.user; // User
  result.data.profile; // Profile
} else {
  result.error.step; // "user" | "profile" | "welcome"
  result.error.error; // what that step failed with
  result.error.completed; // the results of the steps before it
}

Steps ​

Each step has a unique name. A step is given either a value, or a function that receives the results of the earlier steps by name, fully typed.

StepResult under its name
insert(name, changeset, opts?), update, delete, insertOrUpdatethe row (delete also takes a row)
insertAll(name, schema, rows, opts?), updateAll(name, query), deleteAll(name, queryable){ count, rows }
one(name, queryable), all, existsthe query's result (one: the row or null)
run(name, fn)whatever fn returns inside success(...)
put(name, value)the value
inspect(name?, fn)nothing; calls fn with the results so far

Each step takes the same options as the repo method of its name; a query with named parameters takes them after the query, as repo.all does.

run(name, async (tx, results) => ...) is for anything else. It must return success(data) or failure(error); anything else is a TypeError. Use the tx it is given for database work.

ts
const publishAll = createBatch()
  .one("author", from(User, "u").where({ email: "ada@example.com" }))
  .run("found", async (_tx, { author }) => (author === null ? failure("no such author") : success(author)))
  .updateAll("published", ({ found }) =>
    from(Post, "p").where({ authorId: found.id }).set({ published: true }),
  )
  .put("source", "admin")
  .inspect((results) => process.stderr.write(`${Object.keys(results).join(", ")}\n`));

A step's function receives a copy of the results so far: changing it changes nothing.

Composing ​

MethodEffect
merge((results) => batch)Builds more steps from the results so far, at that point in the sequence. It runs when the batch reaches it, inside the transaction.
append(batch), prepend(batch)Joins two batches.
Batch.toList(batch)The steps as data, for tests: { name, operation, input, schema, args } each.

A batch is immutable: every method returns a new one. A duplicate step name does not compile where the types can see it, and throws QueryError when the batch is built (or, for a name a merge adds, when the batch runs).

ts
const audit = (action: string) => (results: { user: User }) =>
  createBatch().insert("audit", change(AuditLog, { userId: results.user.id, action }));

const withAudit = signUp.merge(audit("signed_up"));
ts
const names = Batch.toList(withAudit).map((step) => `${step.operation} ${step.name ?? ""}`);
// ["insert user", "insert profile", "run welcome", "merge "]

Running ​

repo.transaction(batch) runs the steps in order.

  • Checked first. Write steps given a ready-made changeset (not a function) are checked before the transaction opens. If one is invalid, the batch fails at once with that step, its changeset and completed: {}, and no query is sent.
  • The first failure stops it. A write step whose result is a failure, or a run step that returns failure(error), rolls everything back, and the result names the step, its error, and what had completed.
  • A throw is a throw. If a step throws, the transaction is rolled back and the error propagates.
  • Inside a transaction (tx.transaction(batch)) the batch runs in a savepoint.
  • repo.transactionOrThrow(batch) returns the results, and a failure throws RollbackError whose reason is { step, error, completed }:
ts
const { user, profile } = await repo.transactionOrThrow(signUp); // a failure throws RollbackError

repo.transaction(batch, opts) takes the transaction options.

Handling a failure ​

step is typed as the union of the names of the steps that can fail (the write steps, and the run steps that can return failure(…)), so a switch narrows error, and completed to the results of the steps before that one. A run step that only returns success(…) is not in the union, and a batch with no step that can fail has never as its error type.

ts
if (!result.success) {
  switch (result.error.step) {
    case "user":
      return result.error.error.errorMessages(); // Changeset<typeof User>
    case "profile":
      return result.error.error.errorMessages(); // Changeset<typeof Profile>
    case "welcome":
      return { email: [result.error.error] }; // "the queue is full"
  }
}

Batch or transaction callback? ​

  • Use a transaction callback for a one-off sequence with ordinary control flow.
  • Use a batch when the steps are assembled from parts, reused, or when you want to know exactly which step failed without try/catch around each one.

Good to know

  • A batch is a value. Building one runs nothing; repo.transaction(batch) runs it.
  • The first failure stops it, and everything is rolled back.
  • Step names are unique, and the failure says which step failed.

See also ​