> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bijection.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Durable Waits

> Commit work exactly when a business-time boundary is reached

A due date passing, a price expiring, a temporary hold ending: none of these
changes a row by itself. Something has to commit when the moment arrives. A
durable wait is how your program says what that is.

You declare a wait with two of your own internal functions: a query that says
when the next boundary is, and a mutation that does the work at it.

```ts bijection/invoices.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { defineWait } from "bijection/server";
import { internalMutation, internalQuery } from "./_generated/server";
import { v } from "bijection/values";

// When is the next unpaid invoice due?
export const nextDueDate = internalQuery({
  args: {},
  returns: v.union(v.number(), v.null()),
  handler: async (ctx) => {
    const next = await ctx.db
      .query("invoices")
      .withIndex("by_status_due", (q) => q.eq("status", "open"))
      .first();
    return next?.due_at ?? null;
  },
});

// Mark every open invoice that is now past due.
export const markOverdue = internalMutation({
  args: {},
  handler: async (ctx) => {
    const now = Date.now();
    const due = await ctx.db
      .query("invoices")
      .withIndex("by_status_due", (q) =>
        q.eq("status", "open").lte("due_at", now),
      )
      .collect();
    for (const invoice of due) {
      await ctx.db.patch(invoice._id, { status: "overdue" });
    }
  },
});

export const invoiceDue = defineWait({
  due: "invoices:nextDueDate",
  then: "invoices:markOverdue",
});
```

The engine wakes at exactly the instant `nextDueDate` returned and schedules
`markOverdue`. Nothing in this file mentions an interval.

## How a wait works

* **`due` is a read, not a poll.** It is an internal query that returns the next
  boundary in epoch milliseconds, or `null` when there is none. The rows it reads
  become the wait's dependencies, so a committed write that changes its answer,
  such as a new invoice or a paid one, is what re-arms the wait. Between
  boundaries nothing runs.
* **`then` runs through the scheduler.** When the boundary is reached, the engine
  schedules `then`, an internal mutation, in the same transaction that settles the
  wait. It then runs like any
  [scheduled mutation](/scheduling/scheduled-functions), with the same retry
  behavior.
* **The wait survives restarts.** It is stored in the database, like a scheduled
  function.

Write `then` so it checks what is actually due in its own transaction, as
`markOverdue` does, rather than trusting the instant that woke it. Another write
may have changed the data in between.

<Tip>
  Compared with a [cron job](/scheduling/cron-jobs) that sweeps every minute, a
  wait runs only when something is due, and its precision is the boundary itself
  rather than the cron's interval.
</Tip>

## Declaring a wait

Pass `defineWait` an object with:

| Field | Required | Meaning |
| - | - | - |
| `due` | Yes | An internal query returning the next boundary in epoch milliseconds, or `null` |
| `then` | Yes | An internal mutation the engine schedules when the boundary is reached |
| `keys` | No | An internal query listing instance keys, described below |

Each field takes a function reference, such as `internal.invoices.nextDueDate`,
or its `"module:function"` name. Export the declaration from any module in your
`bijection/` folder.

`due` and `then` must be different functions, and `keys` must be a third one.
When you deploy, each name must resolve to a function your deployment declares,
of the right kind, and internal. A deployment that names a missing or public
function is refused.

`then` is resolved again when the boundary arrives, against the code deployed
then. If a later deployment removed it, made it public or changed its kind, the
release is refused with `WaitContinuationWithdrawn` and the wait stays held
rather than running work you no longer declare.

## One wait per key

A single declaration keeps one standing wait. To give each item its own
boundary, add a `keys` query. It returns a list of stable string keys, and each
key becomes its own wait: its `due` runs in its own transaction and receives
`{ key }`, and so does `then`.

```ts bijection/holds.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { defineWait } from "bijection/server";
import { internalMutation, internalQuery } from "./_generated/server";
import { v } from "bijection/values";

export const activeHolds = internalQuery({
  args: {},
  returns: v.array(v.string()),
  handler: async (ctx) => {
    const routes = await ctx.db.query("hold_routes").take(32);
    return routes.map((route) => route.hold_id);
  },
});

export const holdExpiry = internalQuery({
  args: { key: v.id("holds") },
  returns: v.union(v.number(), v.null()),
  handler: async (ctx, { key }) => {
    const hold = await ctx.db.get(key);
    return hold && !hold.released ? hold.expires_at : null;
  },
});

export const releaseHold = internalMutation({
  args: { key: v.id("holds") },
  handler: async (ctx, { key }) => {
    const hold = await ctx.db.get(key);
    if (!hold || hold.released || hold.expires_at > Date.now()) return;
    await ctx.db.patch(key, { released: true });
  },
});

export const holdExpires = defineWait({
  keys: "holds:activeHolds",
  due: "holds:holdExpiry",
  then: "holds:releaseHold",
});
```

* `keys` returns at most 32 keys, each 1 to 128 bytes. A deployment holds at
  most 1024 standing waits in total.
* One key's reads never become another key's dependencies, and a key whose
  `due` throws does not delay the others.
* When `keys` stops listing a key, that key's wait is retired. If `keys` itself
  throws, nothing is retired: a failure to list is not evidence that the items
  are gone.
* `keys` should read routing only, never the protected data the waits guard.
  `then` still rechecks the item, because a key can be removed between the
  boundary and the continuation.

## Limits

* A boundary is a JavaScript millisecond timestamp.
* The only condition a wait can express is "this instant has been reached".
* A wait cannot be created at run time. Every wait comes from a deployed
  `defineWait` declaration, so a program cannot register a condition or a
  continuation the deployment does not declare.

## Source barriers

<Warning>Source barriers are in beta.</Warning>

A decision that reads data synced from an external source sometimes needs that
data to be current or complete first. A source barrier states that requirement,
and the read is refused when it does not hold, instead of returning a stale
answer as if it were current.

You pass a barrier to `ctx.sources.coverage`, which reports what the engine can
prove about one installed source:

```ts bijection/payouts.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { sourceBarrier } from "bijection/server";
import { mutation } from "./_generated/server";
import { v } from "bijection/values";

export const reconcile = mutation({
  args: { sourceId: v.string() },
  handler: async (ctx, { sourceId }) => {
    const coverage = await ctx.sources.coverage(sourceId, {
      table: "payouts",
      barrier: sourceBarrier.completeVersions(),
    });
    // ...read the payouts table and decide
  },
});
```

The barriers are:

| Barrier | Requires |
| - | - |
| `sourceBarrier.none()` | Nothing |
| `sourceBarrier.eachLatest()` | The source has published at least once. A source that has never published does not satisfy it |
| `sourceBarrier.completeVersions()` | The source represents a complete version. A moving, missing, erased or discontinuous source does not satisfy it |
| `sourceBarrier.publishedWith(source, scope)` | Every named source published at or after the anchor source's latest publication. The anchor must be a source the decision reads |

When the barrier is not satisfied, the call throws an error with code
`SourceBarrierUnsatisfied`. The check runs in the same transaction as the reads
that follow it, and the coverage read is tracked like any other read, so a query
that was refused is re-evaluated when the source publishes.

Read on about synced sources in [Integrations](/integrations/overview).
