Skip to content

Sandboxes and testing

Shipped

Before an extension ever reaches a live instance, you test it. Turbobase gives you two complementary tools: an execution sandbox that runs your extension’s code exactly the way production does, and ephemeral local authoring instances you can stand up from a sampled slice of real data. This page disambiguates the two senses of the word “sandbox” and walks the testing commands.

Two senses of “sandbox” — don’t conflate them

Section titled “Two senses of “sandbox” — don’t conflate them”
Term What it is
Code sandbox (this page) The isolated-vm runtime that executes a hook or conduit — a fresh V8 heap, hard memory + CPU caps, no IO, no secrets. The same isolate runs an extension in production, under turbobase ext test, and under turbobase ext replay.
turbobase sandbox … (this page) An ephemeral local authoring instance — a throwaway local database seeded with a sampled, redacted slice of a live instance, with your hooks/ + conduits/ mounted, that reaps itself after a TTL.
An Oracle NetSuite (or other source) sandbox account A provider concept: your ERP’s non-production account. Unrelated to either of the above; you wire one up when setting up a NetSuite connection.

The first two are Turbobase mechanisms; the third is your source provider’s. When this page says “sandbox” with no qualifier, it means the code sandbox.

The code sandbox — how extensions execute

Section titled “The code sandbox — how extensions execute”

Every extension is untrusted customer / AI-authored code, so the runtime never runs it as a live in-process closure. Instead:

  1. esbuild bundles the extension’s TypeScript into one self-contained ESM module. An extension may import the SDK factory (@turbobase/extensions / @/sync/hooks / @/sync/conduits / @/sync/processes), which is resolved to a tiny inline identity stub, and a relative sibling inside its own directory (./shared-rule.js), which is folded into the bundle so the isolate still receives one module. Any other import — an npm package, a Node builtin, a path that escapes the extension’s directory — fails the bundle with a clear error (the “no shared-lib import” fence).
  2. The bundle runs inside an isolated-vm Isolate: a fresh V8 heap with a 16 MB memory cap and a 200 ms per-call CPU/wall timeout, no Node builtins, no IO, no module resolution. The only host surface reachable from inside is the documented HookContext — reject / patch / log plus the read context — bridged across the isolate boundary.
  3. A throw, an OOM, or a runaway loop surfaces as a thrown Error the executor handles exactly as it handled an in-process throw: fail-open by default, or, for a failClosed extension, the operation is refused as a non-verdict — 503 hook_unavailable, retried rather than reported as a validation failure, because an extension the isolate had to kill reached no opinion about the record. Only ctx.reject produces a 422. See fail-open vs fail-closed.

The bridged context exposes no secrets. The isolate cannot reach process.env, the filesystem, or the network — there is nothing to leak even if an extension tried.

Because turbobase ext test and turbobase ext replay reuse the same bundle, the same isolate, and the same effect envelope, a local dry-run and a production firing share one execution path. What passes locally behaves identically deployed.

turbobase ext test — dry-run against samples

Section titled “turbobase ext test — dry-run against samples”

First run on a fresh install? ext test and ext replay compile and execute your extension, which needs two native packages the one-line installer leaves out. Fetch them once with turbobase ext extras install (turbobase ext extras status reports what you have). An npm install -g @turbobase/cli already has them. Every other command works without them.

Run an extension against fixtures in samples/:

Terminal window
turbobase ext test hooks/customer.before_save.ts
# PASS customer-company
# FAIL customer-individual-missing-last (rejected: MANDATORY_FIELD_MISSING)
#
# 1/2 sample(s) passed.

Each fixture is one case; the command exits non-zero on any failure (so it doubles as a CI gate). Generate fixtures from the live instance catalog with turbobase project samples; point at a different directory with --samples <dir>. For machine-readable CI output:

Terminal window
turbobase ext test hooks/customer.before_save.ts --reporter junit > results.xml
turbobase ext test hooks/customer.before_save.ts --reporter json

turbobase ext lint — offline static analysis

Section titled “turbobase ext lint — offline static analysis”

lint catches the semantic foot-guns the Zod schema can’t — a weak conduit idempotencyKey, a missing createdFrom, an extreme cycleBudget, a same-source “conduit.” No instance, no DB, no network:

Terminal window
turbobase ext lint conduits/shop2ns.order.ts
turbobase ext lint conduits/shop2ns.order.ts --ci # exit non-zero on any warning

It reads a hook and a process too, and each rule applies only to the kinds it is about: the four above describe a cross-source bridge, so they are raised on a conduit only, while the deploy-identity check is raised on all three. So --ci is safe to put in a deploy lane that ships every kind — a correct module of any of them exits 0.

turbobase ext replay — drive a conduit’s lifecycle

Section titled “turbobase ext replay — drive a conduit’s lifecycle”

replay is the conduit’s dry-run. It drives the conduit through dispatch guards → from.when → transform → apply-branch → bindings:

Terminal window
turbobase ext replay conduits/shop2ns.order.ts --fixture samples/order.json --trace
  • --fixture <file> (offline, default) runs when + transform through the same isolate the engine’s untrusted path uses; the apply branch is the shared pure rule. No DB, no network.
  • --against sandbox (online) runs the fuller engine path against a running local sandbox (requires a local Postgres); it defers with a clear reason when no local DB is present.

--trace emits the per-stage trace; --reporter junit|json emits one case per stage.

A conduit that declares reads looks something up while it runs, and offline there is nothing to look it up in. So the fixture answers the read: add a queryRows map, keyed by the PostgREST path-and-query string your transform passes to ctx.query — the whole string, exactly as you wrote it:

{
"fromSource": "slack",
"recordType": "channel_message",
"externalId": "C09ABCDEF:1759000111.000200",
"payload": { "scopeRef": "C09ABCDEF", "metadata": { "threadTs": "1759000000.000100" } },
"queryRows": {
"channel_message?select=external_id,body&external_id=eq.C09ABCDEF%3A1759000000.000100&limit=1": [
{ "external_id": "C09ABCDEF:1759000000.000100", "body": "acme/storefront#412 — thread opened" }
]
}
}

Three rules, and the second is the one that makes this workable:

  • The key is compared verbatim. Your transform usually composes its query out of the payload, so the key is whatever that composition produces — percent encoding and all. Nothing normalizes it for you.
  • A key you did not supply answers [], and --trace names it. That is the loop: replay once with no queryRows, read the strings your conduit actually asked for off the trace, and paste them in.
  • Rows are served only to a conduit that declared reads. One that declared none has no ctx.query at all, offline and deployed alike, however much the fixture offers — so rows in a fixture do not buy a read the live conduit would not get. Replay reads that declaration off your file, so write reads: as a literal array on a line of its own, the way the reference conduit does. A reads assembled from a variable, or a whole definition squeezed onto one line, reads as no reads declared offline — you get ctx.query is not a function from a conduit that works once deployed.

--trace prints one line per read, in the order the conduit made them — the from.when gate’s included, since a gate may read too:

ctx.query (declared reads):
channel_message?select=external_id,body&external_id=eq.C09ABCDEF%3A1759000000.000100&limit=1 → served, 1 row(s)
contact?select=external_id,email&external_id=eq.USERX → not served by the fixture, 0 row(s)

That section prints whatever the conduit then does, which is what makes the first replay useful: a read your fixture has not answered yet often stops the conduit dead — the reference reading conduit refuses an empty root outright. The run ends in an error, the error is the trace’s last line, and the strings are still above it:

ctx.query (declared reads):
channel_message?select=external_id,body&external_id=eq.C09ABCDEF%3A1759000000.000100&limit=1 → not served by the fixture, 0 row(s)
ERROR — slack2gh: no mirrored thread root for C09ABCDEF:1759000000.000100

Without --trace, that run prints the one error line and nothing else.

A served read does not mean the live read would be allowed: the declaration checks — an undeclared resource or column, a filter outside reads[].by, the per-run query budget — are the engine’s, and replay does not mirror them. Keep the fixture’s keys inside what reads declares, and read the refusal table in Writing conduits.

turbobase sandbox — ephemeral local authoring instances

Section titled “turbobase sandbox — ephemeral local authoring instances”

When you want to author against something that feels like a real instance — real record shapes, real volume — without touching production, stand up a local sandbox seeded from a sampled, redacted slice of an instance:

Not available yet. turbobase sandbox create refuses and creates nothing. Seeding needs a per-column PII classification for the source instance’s tables, and redaction fails closed on any column it cannot prove is safe — so until that classification exists, a sandbox could only ever be empty or unsafe. Use ext test and ext replay --fixture above; both run offline and cover the authoring loop. The rest of this section describes the intended shape.

Terminal window
turbobase sandbox create --from acme --ttl 72h --sample 2%
# sandbox acme-72h-abc ready.
# source: acme (sampled 2%, redacted)
# url: http://acme-72h-abc.localhost:…
# expires: 2026-06-11T…

create copies a sampled, redacted slice of --from and stamps a TTL. Crucially, the sandbox process forces a sandbox mode that resolves every source connector to a mock / fail-closed stub — no code path in a sandbox reaches a real NetSuite or Stripe. A sandbox is local-only by construction: create, destroy and the reaper all refuse any target that isn’t a localhost database.

List and reap:

Terminal window
turbobase sandbox list # active sandboxes; reaps any past their TTL
turbobase sandbox destroy acme-72h-abc # drop the DB + remove from the registry

list and destroy both opportunistically reap expired sandboxes, so a forgotten sandbox doesn’t linger past its TTL.

  1. turbobase ext new hook … (or new conduit) — scaffold.
  2. turbobase project samples — pull fixtures from the instance catalog.
  3. turbobase ext lint <file> — catch foot-guns offline.
  4. turbobase ext test <file> (or replay for conduits) — dry-run in the isolate.
  5. turbobase sandbox create --from <instance> — exercise against realistic data.
  6. turbobase ext deploy — ship it.