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:
- 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). - The bundle runs inside an
isolated-vmIsolate: 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 documentedHookContext—reject/patch/logplus the read context — bridged across the isolate boundary. - 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
failClosedextension, 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. Onlyctx.rejectproduces a422. See fail-open vs fail-closed.
The bridged context exposes no
secrets. The isolate cannot reachprocess.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 testandext replaycompile and execute your extension, which needs two native packages the one-line installer leaves out. Fetch them once withturbobase ext extras install(turbobase ext extras statusreports what you have). Annpm install -g @turbobase/clialready has them. Every other command works without them.
Run an extension against fixtures in samples/:
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:
turbobase ext test hooks/customer.before_save.ts --reporter junit > results.xmlturbobase ext test hooks/customer.before_save.ts --reporter jsonturbobase 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:
turbobase ext lint conduits/shop2ns.order.tsturbobase ext lint conduits/shop2ns.order.ts --ci # exit non-zero on any warningIt 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:
turbobase ext replay conduits/shop2ns.order.ts --fixture samples/order.json --trace--fixture <file>(offline, default) runswhen+transformthrough 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.
Replaying a conduit that reads
Section titled “Replaying a conduit that reads”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--tracenames it. That is the loop: replay once with noqueryRows, 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 noctx.queryat 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 writereads:as a literal array on a line of its own, the way the reference conduit does. Areadsassembled from a variable, or a whole definition squeezed onto one line, reads as no reads declared offline — you getctx.query is not a functionfrom 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.000100Without --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 createrefuses 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. Useext testandext replay --fixtureabove; both run offline and cover the authoring loop. The rest of this section describes the intended shape.
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:
turbobase sandbox list # active sandboxes; reaps any past their TTLturbobase sandbox destroy acme-72h-abc # drop the DB + remove from the registrylist and destroy both opportunistically reap expired sandboxes, so a
forgotten sandbox doesn’t linger past its TTL.
A typical loop
Section titled “A typical loop”turbobase ext new hook …(ornew conduit) — scaffold.turbobase project samples— pull fixtures from the instance catalog.turbobase ext lint <file>— catch foot-guns offline.turbobase ext test <file>(orreplayfor conduits) — dry-run in the isolate.turbobase sandbox create --from <instance>— exercise against realistic data.turbobase ext deploy— ship it.
Next steps
Section titled “Next steps”- Writing hooks / Writing conduits — the authoring contracts.
- Deploy, plan, promote — the rollout workflow.
- NetSuite OAuth setup — wiring a source provider’s sandbox account.