Skip to content
DomainKit
Esc
navigateopen⌘Jpreview
On this page

Setting up many domains at once

How a batch plans several domains together, binds one digest to one consent, applies with bounded concurrency, and resumes whatever did not land.

A customer moving several domains reviews them as one thing. A batch is that unit: plan every domain, show one digest, take one consent, apply with bounded concurrency, and come back later for whatever did not land.

A batch owns nothing a single domain already has. Each item is a pointer to the attachment and to the attempt carrying that domain’s plan, so the plan, the approval, the receipt, the lease, and the failure all live where they always did.

Planning

/**
 * Plans every domain at once, with `Policy.batchConcurrency` in flight. The key is unique per
 * owner, so a retried request answers with the batch the first one made.
 */
export const start = Provision.batch.create({
  idempotencyKey: "setup-2026-03-04-a41f",
  items: [
    { domain: "one.example.com", requirements: requirementsFor("one.example.com") },
    { domain: "two.example.com", requirements: requirementsFor("two.example.com") },
  ],
});

Every domain must already be attached. Planning reads a provider per domain, so Policy.batchConcurrency bounds how many run at once; it defaults to 4.

A domain that cannot be planned does not stop the ones beside it. Its item records planFailure, the batch stays planning, and nothing is lost.

/** What a review screen needs: the digest to approve, and where each domain stands. */
export const review = (batch: Provision.Batch) => ({
  digest: batch.digest,
  status: batch.status,
  domains: batch.items.map((item) => ({
    attachmentId: item.attachmentId,
    plan: item.plan,
    status: item.status,
    // The one thing an item owns: an item with no plan has no attempt to carry it.
    planFailure: item.planFailure,
  })),
});
/**
 * Re-plan the domains that have none. A batch holds pointers, not requirements, so the host
 * supplies them again.
 */
export const resume = (id: Storage.BatchId) =>
  Provision.batch.resumePlanning(id, {
    items: [{ domain: "two.example.com", requirements: requirementsFor("two.example.com") }],
  });

Resuming plans only the items that have none. A batch stores pointers, not requirements, so the host supplies the requirements again — the same shape create takes.

A batch’s digest is a SHA-256 over its items’ sorted attachmentId:planDigest pairs, so it moves whenever any domain’s plan moves. It is null until every item is planned, and that is exactly when a batch is approvable.

/**
 * One consent for the whole batch, bound to the digest the customer read. A digest the batch's
 * current plans no longer produce fails `BatchStale` and writes nothing.
 */
export const approve = (batch: Provision.Batch) =>
  batch.digest === null
    ? Effect.succeed(batch)
    : Provision.batch.approve(batch.id, { digest: batch.digest });

Approval writes one Approval per attempt, each bound to that attempt’s own plan digest, together with the batch’s own approval in a single transaction. A batch is never approved without the per-attempt approvals apply takes, and an attempt is never approved for a batch that was not.

A digest the batch’s current plans no longer produce fails BatchStale and writes nothing. Read the batch again and show the customer what moved.

Applying

/**
 * Applies every approved domain that has no receipt yet. A domain another apply holds waits for
 * the next round, and one that fails does not stop the ones beside it, so calling this again
 * resumes the batch.
 */
export const apply = (id: Storage.BatchId) => Provision.batch.apply(id);

Apply walks the approved domains with Policy.batchConcurrency in flight, each under its own attempt lease. Two applies of the same batch never write the same record twice: the one that loses a lease finds that domain Busy and skips it.

A domain that fails records its failure on its own attempt rather than stopping the others, so calling apply again re-claims it. A domain whose write failed after an earlier one landed has a partial receipt and needs a new plan, exactly as it would on its own.

Where a batch stands

Status Means
planning At least one domain has no plan yet
planned Every domain is planned and the digest is ready to approve
approved Consent recorded, nothing applied yet
applying An apply is in flight
complete Every domain is done
partial A domain’s write failed after an earlier one landed; re-plan
failed A domain stopped before any write; apply again
rejected The customer declined, and every plan under it with them

The status is recomputed from the items’ attempts on every transition, so it never disagrees with the domains it summarizes. complete and rejected are terminal.

/** The index behind a "you still owe this" banner: no plan is read, so it stays cheap. */
export const unfinished = Provision.batch.list({ unfinished: true });

list({ unfinished: true }) reads no plans, which is what makes a “you still have a setup waiting” banner cheap. Open one batch and Provision.batch.get pays for that one.

Declining

/** Terminal, and it declines every plan under the batch. Only while nothing is approved. */
export const decline = (id: Storage.BatchId) =>
  Provision.batch.reject(id, { reason: "Wrong domains" });

Rejection is terminal, and it declines every plan under the batch. Declining again returns the same batch; a batch that was already approved fails BatchStale.

A planning pass runs outside any transaction, because it reads a provider. Landing its result checks the batch’s state in the same transaction as the write, so a pass still in flight when the customer declines lands nothing.

Retrying a create

idempotencyKey is unique per owner. A retried create — a double-clicked button, a client that resent the request — answers with the batch the first call made instead of planning the same domains twice. Over HTTP the key is the Idempotency-Key header on POST /batches; the domainkit/server reference lists every batch route.

export const explain = (batch: Provision.Batch) =>
  Match.value(batch.status).pipe(
    Match.when("planning", () => "Some domains still need a plan; resume planning."),
    Match.when("planned", () => "Ready for the customer to approve."),
    Match.when("approved", () => "Approved; apply it."),
    Match.when("applying", () => "An apply is in flight."),
    Match.when("partial", () => "Some records landed and some did not; re-plan those domains."),
    Match.when("failed", () => "At least one domain stopped before any write; apply again."),
    Match.when("complete", () => "Every domain is done."),
    Match.when("rejected", () => "The customer declined."),
    Match.exhaustive,
  );

Was this page helpful?