AbsoluteJS

unsafeHost escape hatch

@absolutejs/sync@1.12.0 adds an opt-in escape hatch on sandboxedHandler. By default, a sandboxed mutation is hermetic — it can read args and ctx and call actions.*, nothing else. When a handler explicitly needs to reach a third-party API, queue, mailer, or any other side effect that lives on the host, you declare those host functions in sandbox.unsafeHost and the engine exposes them to the sandbox-sideunsafeHost Proxy. The name is loud on purpose: every appearance in source code says "this leaves the safe-deterministic surface."

#Live worked example

The examples/sync app's React page renders an UnsafeHostPanel with a form that fires a sandboxed audit:emit mutation. The handler runs inside isolated-jsc, reaches through to unsafeHost.shipToWebhook (a stand-in for a real outbound integration), and writes the resulting record through actions.insert('audit_log', …). The panel reads two signals: the live audit_log collection (proving the transactional write committed) and a host-side counter exposed at /sync/audit/webhook-calls (proving the host fn fired). The Playwright test asserts both effects landed.

#When to reach for it

Convex calls these "actions" — non-deterministic entry points that can talk to the outside world. Sync's escape hatch is the same trade with a different shape: instead of a separate mutation kind, the same sandboxedHandler can interleave deterministic writes (via actions.*, which the engine batches and rolls back on retry) with explicit host calls (via unsafeHost.*, which it does not). Use it when:

  • You need to charge a card, send an email, push to a queue, or otherwise touch the outside world during a mutation.
  • The host call has an idempotency story you can pass through (Stripe's Idempotency-Key, SQS's MessageDeduplicationId, a row-keyed upsert), OR you've turned off retries on the mutation.
  • The work belongs inside the mutation's call graph — the model or the API caller should see one operation, not a chain of two.

If the host call shouldn't run on retry at all, model it as a follow-up step (a schedule, a webhook, an HTTP-route side effect after the mutation commits). The escape hatch is for "inside the same call graph and we know what we're doing" — not for "I needed I/O and sandboxedHandler was inconvenient."

#The signature

The wrapped handler signature is now (args, ctx, actions, unsafeHost). Existing handlers ignore the 4th param; the change is fully backwards-compatible. Without an unsafeHost map the Proxy throws on every access, so a handler that "didn't know it was hermetic" can't accidentally pierce the sandbox.

TS
// Without an unsafeHost map, the sandbox is hermetic — the wrapped
// signature is still 4-arity but the 4th param's Proxy throws on every
// access. This is BY DESIGN: a sandboxed handler with no opt-in can't
// reach the outside world by accident.
defineMutation({
  name: 'safe:doWork',
  sandboxedHandler: `async (args, ctx, actions, unsafeHost) => {
    // unsafeHost.anything() throws "not declared" — there's no map.
    await actions.insert('items', { ...args, status: 'done' });
    return { ok: true };
  }`,
});

// Opting in is explicit. Each entry the model can call must be NAMED
// here. The names you pick are what shows up in the handler source —
// so name them visibly: chargeStripe, sendSlackPing, pushToSqs.
defineMutation({
  name: 'payments:checkout',
  sandboxedHandler: `async (args, ctx, actions, unsafeHost) => {
    const order = await actions.insert('orders', {
      ...args, status: 'pending',
    });
    const receipt = await unsafeHost.chargeStripe({
      amount: order.amount,
      token: args.token,
    });
    await actions.update('orders', {
      id: order.id, status: 'paid', receiptId: receipt.id,
    });
    return order;
  }`,
  sandbox: {
    unsafeHost: {
      chargeStripe: ({ amount, token }) =>
        stripe.charges.create({ amount, source: token }),
      // Add more as needed. Each name is a hole in the sandbox.
    },
  },
});

#Why the loud name

unsafeHost is deliberately loud. Every appearance in source tells the reader two things at once: this call leaves the sandbox, and the deterministic-mutation guarantees stop here. Grep-friendly for CI, conspicuous in review diffs, hard to mistake for a safe primitive.

TS
// The name was chosen deliberately to be LOUD. The alternatives we
// considered and rejected:
//
//   ctx.runAction(...)  // Convex's name — opaque, hides the danger
//   host.fnName(...)    // too generic, doesn't say what changes
//   escape.fnName(...)  // doesn't describe the surface
//   effects.fnName(...) // hides the "you broke determinism" angle
//
// 'unsafeHost' is the right name because every time it appears in
// source code, the reader knows:
//
//   - "Host" — this leaves the sandbox.
//   - "Unsafe" — the deterministic-mutation guarantees stop here.
//
// Three syllables in a code-review diff. Easy to grep for in CI
// (`grep -r 'unsafeHost\.' src/`). Easy to enforce a policy that
// every entry in the unsafeHost map needs a security review.

#The retry pitfall

The engine's per-mutation retry wrapper sits OUTSIDE the sandbox. If the handler throws after an unsafeHost call has already happened, and the engine retries, the host fn fires again. The mutation's writes are transactional — they rolled back. The host fn's side effect is not.

Three escapes, in order of preference: make the host fn idempotent at the protocol layer; turn off retries on the mutation; or move the host call to a follow-up step that runs after the mutation commits.

TS
// Retry-fires-twice pitfall. The sandbox runs inside the engine's
// per-call retry + DB-transaction wrapper. If the handler throws AFTER
// an unsafeHost call has already run AND the engine retries, the
// host fn fires AGAIN. The mutation's writes are transactional and
// safe; the host fn is not.
//
// Options, in order of preference:
//
// 1. Make the host fn idempotent at the protocol layer.
//    Stripe takes an Idempotency-Key header that returns the same
//    response for the same key — pass args.orderId.
defineMutation({
  name: 'payments:checkout',
  retry: { maxAttempts: 3 },
  sandboxedHandler: `async (args, ctx, actions, unsafeHost) => {
    const order = await actions.insert('orders', { ...args });
    // Same orderId every attempt → Stripe returns the cached response.
    const receipt = await unsafeHost.chargeStripe({
      idempotencyKey: order.id,
      amount: order.amount,
      token: args.token,
    });
    await actions.update('orders', { id: order.id, status: 'paid' });
    return order;
  }`,
  sandbox: {
    unsafeHost: {
      chargeStripe: ({ idempotencyKey, amount, token }) =>
        stripe.charges.create(
          { amount, source: token },
          { idempotencyKey },
        ),
    },
  },
});

// 2. Skip retries for the mutation.
//    Remove the retry option — the engine commits once, fails loudly
//    on conflict. Best when the host fn has no idempotency story.
//
// 3. Move it to a separate workflow.
//    If the host fn fundamentally shouldn't retry, model it as a
//    follow-up step (a schedule, a job, an HTTP-route side effect)
//    that runs AFTER the mutation commits. The mutation's writes are
//    safely atomic; the host call sits outside the transaction loop.

#Failure modes

Two failure paths are visible and both are actionable. Calling a host fn that wasn't declared throws a typo-friendly error with the name in it; a host fn that throws propagates the error into the sandbox as a normal JS Error the handler can try / catch.

TS
// Failure modes are explicit.
//
// 1. Undeclared name. The sandbox-side Proxy lets you access any
//    property, but the engine refuses anything not in your config:
defineMutation({
  name: 'badEscape',
  sandboxedHandler: `async (args, ctx, actions, unsafeHost) => {
    return await unsafeHost.notDeclared({ x: 1 });
  }`,
  sandbox: { unsafeHost: { onlyThis: () => 'ok' } },
});
//   → throws: "sandboxedHandler called unsafeHost.notDeclared() but
//     it was not declared in the mutation's sandbox.unsafeHost
//     config. Declare it (and only the host fns you intend to expose)
//     to opt in to the escape hatch."
//
// 2. Host fn throws. The thrown error structured-clones across the
//    isolate boundary and reaches the handler as a normal JS Error.
//    Use try/catch in the sandbox source — same shape as catching a
//    fetch failure.
defineMutation({
  name: 'payments:checkout',
  sandboxedHandler: `async (args, ctx, actions, unsafeHost) => {
    try {
      const receipt = await unsafeHost.chargeStripe(args);
      await actions.update('orders', { id: args.orderId, status: 'paid' });
      return { ok: true, receipt };
    } catch (e) {
      await actions.update('orders', { id: args.orderId, status: 'failed' });
      return { ok: false, error: e.message };
    }
  }`,
  sandbox: { unsafeHost: { chargeStripe: stripeChargeFn } },
});