Telnyx Storage: SQL (Beta) — Full Documentation
Complete page content for SQL (Beta) (Storage section) of the Telnyx developer docs (https://developers.telnyx.com). Root index: https://developers.telnyx.com/llms.txt · Lightweight index for this subsection: https://developers.telnyx.com/development/llms/storage-sql-llms-txt.md
Get Started
SQL Databases
Source: https://developers.telnyx.com/docs/edge-compute/sqldb.mdA SQL database is a SQLite database that lives on its own, outside any function. Create it once, bind it by id, and query it as
env.DB from as many functions as need it — or from the CLI and REST API without deploying anything at all.
There is no server to size and nothing to deploy per database. Every path reaches the same data through one primary, so writes serialize and there is no replica lag to reason about.
Two Ways to Reach a Database
The same database is reachable two ways. Pick based on where the code runs.
A row written by a function is visible to the next CLI query, and a table created from the CLI is visible to the next request. That is what lets schema exist before any code does: create the database, apply migrations, then ship a function that binds it.
SQL Databases vs Per-Actor SQL
Stateful Actors also have SQLite, atctx.storage.sql. Both surfaces are SQLite, which is where the confusion starts. They solve different problems.
A per-actor database cannot be queried from outside its actor and cannot join across instances; there is no CLI or REST path to it. A SQL database can be queried from anywhere, but no caller gets exclusive access to it — concurrent callers interleave, and correctness across statements comes from
batch(), not from holding the database. Explicit BEGIN is rejected on both surfaces; the runtime owns transaction boundaries.
Next Steps
- Quick Start — Create a database, apply a schema, bind it, query it
- How SQL Databases Work — The single primary, sharing, and what durability means today
- Migrations — Versioned schema changes with
storage sqldb migrations - Runtime API — The
envbinding surface (SqlDatabase) - CLI Commands — Create, inspect, and query databases from the terminal
- Limits — Size and duration ceilings, and known gaps
Related Resources
- Bindings — How the
envbinding surface works - Stateful Actor SQL — The private, per-instance SQLite database
- KV — Key-value storage for read-heavy lookups
- CLI reference — The full
telnyx-edgecommand surface
Quick Start
Source: https://developers.telnyx.com/docs/edge-compute/sqldb/quick-start.md
Prerequisites
- The
telnyx-edgeCLI, authenticated (telnyx-edge auth api-key set <YOUR_API_KEY>), on a release that includesstorage sqldb. - A Telnyx API key, if calling the REST API directly.
- An Edge Compute function project with a
telnyx.toml(orfunc.toml) manifest, and Node.js fornpm.
1. Create a Database
A database is an isolated SQLite database with its own schema. Create one with the CLI or the API.id — a UUID, and the only durable handle. Names are not resolution keys:
409, and the API rejects an empty or upper-case name with 422 (the CLI stops an empty name before any request is made). Hyphen placement is not constrained — a leading hyphen is accepted — but avoid one: that name then reads as a flag when passed to CLI commands, so you are left addressing the database by id. Keep names short: past 255 characters the create fails with a 500 instead of a validation error.
A new database starts at status: "pending". SQL sent to it from outside a function — execute, migrations, or the REST query endpoint — returns 409 ("Database is not ready (status: pending)") until provisioning finishes, typically in 2 to 11 seconds. If scripting, poll until the status is provision_ok rather than sleeping a fixed interval:
2. Create the Schema
Create your tables from the terminal, before any code exists.execute takes exactly one of --command or --file, and --remote is required — there is no local emulation.
--json to get the raw result object instead of the rendered table. For scripting note one wrinkle: the current CLI writes the JSON to stderr, so capture it with 2>&1 >/dev/null rather than piping stdout.
execute is for one-off statements and inspection. For schema changes intended to be kept and replayed on another database, use Migrations — numbered .sql files with a tracking table inside the database itself.
3. Bind the Database to a Function
Declare the database in the function’s manifest. The manifest itself — and the[edge_compute] identity in it — comes from the function, not from this guide: telnyx-edge new-func registers the function and writes func_id when it scaffolds a project. Starting from nothing, run telnyx-edge new-func -n links-api -l ts first and work in the project it creates (it writes a classic func.toml; for the umbrella telnyx.toml form shown here, copy the generated [edge_compute] block across). The only part this step adds is the [storage.sqldb.DB] block.
The example below is a telnyx.toml; the [storage.sqldb.<NAME>] block parses the same way in a func.toml project, though a classic func.toml project’s entry point has a different shape from the export default { fetch } handler in step 5:
DB here — is a name of your choosing, and it is what the code sees on env: this manifest produces env.DB. It must be a valid identifier (letters, digits, underscores; no leading digit, no __) and unique across every binding in the manifest.
id must be the database UUID. Binding by name, or creating a database from the manifest, is rejected before deploy. Declare as many [storage.sqldb.<NAME>] blocks as needed; two bindings that carry different ids are two different databases, and neither can see the other’s tables.
4. Generate Types
telnyx-edge types reads the manifest — plus the @telnyx/edge-runtime version declared in package.json — and nothing else: it runs offline and needs no authentication. Re-run it after adding, renaming, or removing a binding.
Types are for the compiler only. The binding itself resolves at runtime from the manifest, so a stale telnyx-env.d.ts does not change what the deployed function sees — in either direction.
5. Write the Function
Bindings live on theenv imported from @telnyx/edge-runtime — not on the second argument to fetch(req, env). Reading env.DB off that argument type-checks cleanly and is undefined at runtime.
prepare() is synchronous and returns a statement. .bind() returns a new statement with the values attached — it never mutates the one it was called on. The terminal calls are async, and each resolves to a different shape: run() and all() give { results, success, meta }, first() gives a single row or null (first("col") gives that column’s value), and raw() gives an array of value-arrays. run() and all() are the same call: both return rows, including the rows a RETURNING clause produces.
Bind every value that comes from a request with ? placeholders rather than building SQL by concatenation. Ordered ?NNN placeholders work too.
A failing statement — a syntax error, a UNIQUE violation, a missing table — rejects with a plain Error. There is no typed error class or error-code taxonomy: the SQLite message is nested inside a longer transport string on error.message.
6. Ship and Call It
ship bundles the project, uploads it, and prints the function host — {func-name}-{func-id-prefix}.telnyxcompute.com, without a scheme; add https:// when calling it.
7. Query It Without Deploying
The rows the function wrote are readable from the terminal against the same database:env.DB, reached through the same primary — the CLI is not a second writer, and no schema or data is duplicated. Use it to inspect state, apply a fix-up statement, or seed a table without redeploying.
8. Query It From Any Language
The CLI is a wrapper over one HTTP endpoint. Anything that can make a request can reach the same database — a Go or Python function, a backfill script, a CI job:sql. Any other key — including params — is rejected with 400 Invalid request body. The response carries results and nothing else: no meta, no row counts, no duration. A script may hold several ;-separated statements, and the rows returned are the last statement’s.
This endpoint has no parameter binding. There is no way to send values separately from the statement, so any value that varies has to be written into the SQL text itself. Never interpolate a string that came from a request, a user, or any other untrusted source into a statement sent this way — that is a SQL injection, and the endpoint gives you nothing to defend with.
Use this path for schema, migrations, backfills, and inspection, where the SQL is written by you and fixed in advance. For request-path queries whose values come from callers, use a TypeScript function and the env binding, where prepare().bind() keeps the statement and the data separate.
Binary columns come back as JSON arrays of byte values ([9, 8, 7]), not base64. Errors arrive in the standard Telnyx envelope: a SQL failure is a 422 whose detail embeds the SQLite message inside the same longer transport string described in step 5, a database that has not finished provisioning is a 409, and an unknown or other-organization id is a 404.
Next Steps
- How SQL Databases Work — the shared-database model, consistency, and what
env.DBis talking to - Migrations — numbered
.sqlfiles and the in-database tracking table - Runtime API — the full
SqlDatabasesurface, includingbatch()andexec() - CLI Commands —
create,list,get,delete,execute,migrations - Limits — database and value size, bound parameters, and query performance
- Bindings — how the
envbinding surface works across storage products
Concepts
How SQL Databases Work
Source: https://developers.telnyx.com/docs/edge-compute/sqldb/concepts/how-sqldb-works.mdA SQL database is stock SQLite, addressed by UUID, living outside any function. Everything in the model follows from one property: a database has exactly one primary, and every caller goes through it.
A Database Is a Standalone Resource
Create a database with the CLI or the REST API and it comes back immediately with statuspending; it reaches provision_ok a few seconds later — typically 2 to 11, since provisioning is picked up by a poll loop rather than done inline. A query issued while it is still pending returns HTTP 409 Database is not ready. In a script, poll get until the status is provision_ok rather than sleeping a fixed interval.
The UUID is the only durable handle. A telnyx.toml binding takes an id, never a name:
^[a-z0-9-]+$. It exists so humans can find a database in a list; the CLI resolves a name to an id locally as a convenience, while REST calls and manifests take the id. There is no rename, and deleting a database frees its name for a new, empty database with a different id.
Nothing is deployed per database, and nothing about a database is derived from a function. Shipping, rolling back, or deleting a function does not touch the data.
The engine is stock SQLite — 3.51.3 in production today — so the dialect and the built-in functions are SQLite’s, not a subset invented for the edge. Foreign keys are on and enforced, which is worth knowing if you are arriving from a SQLite build that leaves them off: a REFERENCES violation fails the statement.
One Primary, Serialized Writes
Today every read and every write for a given database funnels through a single primary, so a caller never contends with a second writer: not another region, not a replica, not a second connection opened by a different caller. That is the current topology rather than a promise about future ones; what you can design against is that statements against one database are serialized, and that every access path sees the same data. Two consequences, both worth designing around:- Writes are serialized. Concurrent writers queue behind one another instead of contending for the database. Lost-update anomalies are still possible at the application level — two callers that each read, compute, and write can still clobber each other — which is what
batch()exists for. - Statements run one at a time. The database is single-threaded. A slow statement occupies it and other callers wait behind it. That is the cost of the property above.
SELECT 1 round-trips in about 10 ms warm and a 5,000-row read in about 160 ms, so the cost that matters is the one a slow statement imposes on everything queued behind it. Long analytical scans belong in a warehouse, not here — see Limits.
Indexes matter for the same reason they matter in any SQLite deployment, and more so because the cost is paid by every other caller:
Two Access Paths, One Database
A database is reachable from inside a function through its binding, and from outside through REST or the CLI. These are not two systems kept in sync — both run against the same primary.
Visibility between them is immediate and needs no coordination. A row inserted by a function is returned by the very next
sqldb execute; a table created by sqldb execute is visible to the next request that runs. That is what makes schema management work before any code exists: create the database, apply migrations from a terminal, then ship a function that binds it.
Inside a function the binding is read off the env imported from @telnyx/edge-runtime, not off the second argument to fetch(request, env). That argument does not carry storage bindings, so env.DB read from it is undefined at runtime even though it type-checks. Requires @telnyx/edge-runtime 0.9.0 or later.
Sharing Across Functions
A database is not owned by the function that first wrote to it. Any number of functions can declare a binding to the same id, and each one sees the same tables and the same rows. The binding name is a local alias, declared per function. One function can call the databaseDB and another can call the same id SHARED; both statements land in the same place.
batch() where a sequence has to be atomic.
The id is validated when a function ships. An id that does not exist, belongs to another organization, or is not yet provision_ok fails the deploy rather than producing a function with a dead binding — though the failure currently surfaces as a generic API error (HTTP 500) that never names the binding, so if a ship starts failing right after a [storage.sqldb] edit, re-check the id and the database’s status before believing the retry-later advice. The check reads the database record rather than opening a connection, so it is not a promise that the first query will succeed — only that the id resolves to a database this organization owns.
Isolation
Isolation runs along two lines, both enforced by the platform rather than by convention.- Database to database. Two bindings in the same function are two separate databases.
env.DBcannot seeenv.DB2’s tables andenv.DB2cannot seeenv.DB’s — each direction fails withno such table. There is no cross-database query: each statement addresses exactly one id, and one database cannot be joined to another. - Organization to organization. A database belongs to the organization that created it. An id from another organization returns
404— the same response as an id that never existed, so existence cannot be probed. A function can only ever reach its own organization’s databases.
Consistency and Durability
Because there is one primary and no replicas, consistency is the simple kind:- A write that resolves has committed. When
all(),run(),batch(), orexec()resolves without throwing, the transaction has committed on the primary and every later reader sees it. - Reads reflect prior writes. Both access paths reach the same primary, so there is no replica lag to design around and no eventual-consistency window between the function path and the REST path.
- Data outlives functions. Rows written before a deploy are there after it — across redeploys of the functions that bind the database, across CLI and REST sessions, and across the creation of other databases in the same organization.
status: deleting — queries return 409 and the name is still held — and then the id turns 404 and the name frees. Re-creating the same name after that produces a new, empty database under a new id, so poll get until the 404 before re-creating. Keep the schema in version-controlled migration files.
Choosing Between SQL, KV, and Per-Actor SQLite
Three storage surfaces, three different scopes. They can be used together in one application.
The distinction that catches people is the last column. Per-actor SQLite is the same engine but a private database per instance — it cannot be queried from the CLI, cannot be joined across instances, and has no REST path. A SQL database is the opposite: queryable from anywhere, but no caller ever holds it exclusively.
Related Resources
- Quick Start — Create a database, apply a schema, bind it, query it
- Runtime API — The
SqlDatabasebinding surface method by method - Type Conversion — What
bind()accepts and what queries hand back - Migrations — Versioned schema changes
- Limits — Size and duration ceilings, and known gaps
- Bindings — How
envbindings resolve - Stateful Actor SQL — The private, per-instance SQLite database
Migrations
Source: https://developers.telnyx.com/docs/edge-compute/sqldb/migrations.mdA migration is a numbered
.sql file on disk. telnyx-edge storage sqldb migrations creates
those files, applies the ones that have not run yet, and records what it applied — in a table
inside the database, not in a state file beside your code. Three commands cover the whole
workflow:
--remote is required on list and apply. There is no local database to run against —
local execution is not supported, and there is no --local flag. create is the exception:
it only writes a file, so it needs no authentication and no network.
A Worked Example
Start with a provisioned database namedlinks-db. Create the first migration:
list now reflects the new state:
apply again does nothing — it only ever runs what is pending:
The Ledger Lives in the Database
Applied state is tracked in a table calledsqldb_migrations, created inside the database on
the first apply:
nameis the filename, not the label — renaming a file after it has been applied makes it pending again.applied_atis an RFC 3339 UTC timestamp generated by the CLI at apply time, so it reflects the clock of the machine that ran the command.idis assigned by SQLite in apply order.
list never creates the table — it is read-only, and treats a missing sqldb_migrations as
“nothing applied yet”. Only apply creates it.
Apply Is Not All-or-Nothing
Each file is submitted as one script in one call: the migration body, then theINSERT
into sqldb_migrations that records it. A script is applied atomically: if any statement in it
fails, the whole script rolls back — a script that creates a table, inserts a row, and then hits
a bad statement leaves no table behind. So a migration file either applies completely and is
recorded, or changes nothing and is not recorded. There is no state in between for a single file.
Across the batch there is no atomicity at all. If the third of five migrations fails, the first
two stay applied and the command stops:
✗ line relays the API error verbatim, so the SQLite message — duplicate column name: owner here — arrives embedded in a longer transport string rather than on its own. The command
exits non-zero with an error that begins
migration 0003_add_owner.sql failed after applying 2 migration(s):, followed by the same
relayed API error. The database is left
partway through the batch — schema changes from 0001 and 0002 are live, and 0003 is not
recorded, so apply will try it again.
Recover by fixing forward:
- Run
migrations list <database> --remoteto see exactly where the batch stopped. - Correct the file that failed. It is still pending, and
applyruns pending files in numeric order, so the next run reaches it before anything numbered after it — a later migration cannot reconcile around it. Edit that file until it succeeds, or empty it out if the change is no longer wanted. - Run
apply --remoteagain. Only the still-pending files run, beginning with the one that failed. - Prefer statements that are safe to retry —
CREATE TABLE IF NOT EXISTS,CREATE INDEX IF NOT EXISTS,INSERT OR IGNORE— because a retry re-runs the failed file from the top.
applied and its new contents never run.
Nothing coordinates two apply runs against the same database. apply reads the ledger and
then submits each pending file as its own call, so two runs started at the same time — two CI
jobs, or a pipeline racing an operator — can both see a file as pending and run it twice. Run
migrations from one place, and write them so that a second run is harmless.
Foreign keys are on and enforced. A migration that rebuilds or drops a referenced table has to
order its statements so no statement leaves a dangling reference — a foreign-key violation
fails the file like any other error.
Files and Directories
create numbers files with a 4-digit zero-padded, auto-incrementing prefix
(0001_, 0002_, …), taking the next number from the highest prefix already in the
directory. The label is the name argument with every run of non-alphanumeric characters
collapsed to _, and leading or trailing runs dropped.
Files land in migrations/<database> by default, with the same collapsing applied to the
database argument — links-db becomes migrations/links_db:
app-db and app--db both resolve to
migrations/app_db. Give one of them an explicit --migrations-dir so their files stay apart.
Override the location with --migrations-dir:
.sql suffix is picked up, whatever
the digit count. Files run in numeric-prefix order, then filename order.
Machine-Readable Output
apply --json always emits the same shape, including on a no-op run ({"applied": []}), and
emits no JSON at all on failure. One wrinkle in the current CLI: the JSON — for apply and
list both — is written to stderr, not stdout, so capture it with 2>&1 >/dev/null
rather than piping stdout:
list --json emits one entry per local file:
Related
- CLI — the full
storage sqldbcommand surface - Quick Start — create a database, bind it, query it
- Limits — the ~4 MiB script ceiling and query duration
- How SQL Databases Work — one primary per database
Reference
Overview
Source: https://developers.telnyx.com/docs/edge-compute/sqldb/reference.mdThe types in this reference are exported from
@telnyx/edge-runtime (TypeScript) and describe version ≥ 0.9.0 — the first release that exports SqlDatabase and wires the env binding. They describe the env binding, the in-function surface. Running SQL from outside a function — schema changes, ad-hoc queries, migrations — goes over a separate REST path, covered in the CLI reference.
The SQL Databases Runtime API is a single binding type plus the statement and result types its methods return. A database declared as [storage.sqldb.<NAME>] in telnyx.toml resolves on env.<NAME> as a SqlDatabase.
Getting the Binding
env — not the env argument of fetch(request, env). The object handed to fetch carries actor bindings only: env.DB on it is undefined, and the call fails at runtime with Cannot read properties of undefined (reading 'prepare') even though it type-checks cleanly. The generated types deliberately declare the binding in both places, so the compiler will not catch this.
Declaring the binding is covered in the Quick Start. The binding resolves at runtime from telnyx.toml; run telnyx-edge types after editing the manifest to regenerate telnyx-env.d.ts, which types env.<NAME> as a SqlDatabase. That command checks the @telnyx/edge-runtime version declared in package.json and fails with @telnyx/edge-runtime <version> does not export SqlDatabase (requires >= 0.9.0) when the floor is not met.
Related Resources
SqlDatabase— the method-by-method reference- Quick Start — create a database, bind it, query it
- How SQL Databases Work — one primary per database, and what that means for consistency
- Bindings — how bindings resolve on
env - CLI —
telnyx-edge storage sqldbfor provisioning, ad-hoc SQL, and migrations - Limits — database and value size, bound parameters, and query performance
- Stateful Actor SQL — a different product: the private embedded SQLite at
ctx.storage.sql, visible to one actor instance only and called synchronously
SqlDatabase
Source: https://developers.telnyx.com/docs/edge-compute/sqldb/reference/sql-database.md
env.<BINDING> (a SqlDatabase) is the in-function handle to a SQL database. The runtime routes the call for you, so function code holds no API key. Every database is served by one primary that serializes writes, and the binding, the CLI, and the REST query endpoint all funnel through that same primary — a row written from a function is immediately readable from the CLI, and the reverse.
all() and run() hand back rows typed as plain records, and raw()
hands back arrays of values.
Key behaviors:
prepareis synchronous; every terminal is async.preparebuilds a statement locally — nothing crosses the wire untilfirst,run,all,raw,batch, orexecis awaited.bindreturns a new statement. It never mutates the statement it was called on, so a prepared statement can be reused with different values.run()is an alias forall(). Both send the same request and return the sameSqlQueryResult, rows included —run()on aSELECTreturns rows. This matches Cloudflare D1.batch()is atomic. All statements commit together or none do.exec()returns no rows — only a statement count. Use it for schema scripts.successis alwaystrueon a resolved promise. Failures reject; they are never reported through the flag.- Errors throw plain
Errorobjects. There is no typed error class and no error-code taxonomy. See Errors. - Transaction control statements are rejected. SQL containing
BEGIN,COMMIT,ROLLBACK, orSAVEPOINTfails when it runs — through a prepared statement or throughexec()— withSqlTransactionControlErrorin the message. Transaction boundaries belong to the runtime; usebatch()for all-or-nothing writes.
Reading What a Write Touched
meta reports the statement’s own accounting — last_row_id, changes, rows_read, rows_written, and duration.
RETURNING is the more precise option, and worth preferring for anything you branch on. It names the rows the statement actually touched, so it survives concurrency: every caller of a database shares one connection, and meta describes the statement that most recently ran on it.
RETURNING on INSERT, UPDATE, and DELETE, and the rows it hands back are exactly the rows the statement touched. SELECT changes() reports the count instead, but it reads whichever statement most recently ran on the shared connection, so a concurrent write can land in between. When a bare count is all you need, issue it in the same batch() as the write so nothing can interleave:
prepare(query)
Build a statement. Synchronous, and safe to call once and reuse.
;-separated statements. All of them execute in order, and the rows that come back are the final statement’s — the same rule Cloudflare’s Durable Object SQL storage follows. Bound parameters apply to that final statement only: place placeholders nowhere else, because the earlier statements run without bindings and a ? in one of them fails.
bind(...values)
Attach values to the placeholders in the query and get back a new statement. Always bind user input — never build SQL by string concatenation.
? and ordered ?NNN (1-indexed) both work, and values are consumed in argument order.
Type Conversion
How values cross between JavaScript and SQLite, in both directions — whatbind() accepts on the way in, and what the terminal methods hand back on the way out.
Four JavaScript types reach SQLite — null, number, string, and ArrayBuffer — plus undefined, which is accepted and stored as NULL. Every other value throws, and it throws when the statement runs rather than at the bind() call, because bind() performs no validation.
Store a boolean as
0/1 and binary as an ArrayBuffer:
.buffer is the whole backing store, not the view. For a view that does not span its buffer,
slice first: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength).
One bound value is capped at 2 MiB (2,097,152 bytes). A larger ArrayBuffer or string is
rejected with SqlParameterTooLargeError before the statement runs. This is a ceiling of the
storage engine SQL Databases share with
per-actor SQLite, and it applies only
to values sent through .bind() — a value produced inside SQL, such as randomblob(), is not
bound and is not checked. Split larger payloads, or keep them in
object storage and bind the key.
A bound number arrives with REAL affinity — SELECT typeof(?) on a bound 42 returns real. Inserted into a column declared INTEGER, it still lands as an integer (typeof(col) reads back integer); the affinity only shows in untyped or expression contexts. In a STRICT table the check is exact, so binding 3.5 into an INTEGER column correctly fails with cannot store REAL value in INTEGER column.
Reading an integer larger than 2^53 − 1 throws RangeError: Value is too large to be represented as a JavaScript number. SQLite stores 64-bit integers, but result columns come back as JavaScript numbers — a value past Number.MAX_SAFE_INTEGER (9007199254740991) can be written and compared in SQL, but the read fails loudly rather than losing precision. Store large ids, snowflake keys, and nanosecond timestamps as TEXT.
first() and first(column)
Return the first row, or a single column of it. No meta.
first() resolves to null when there are no rows. first(column) returns the value at that key; if the column is not in the result set it also resolves to null rather than throwing — a divergence from D1, which raises D1_COLUMN_NOTFOUND.
run() and all()
Run the statement and return the full envelope. The two methods are identical.
results holds the rows as objects keyed by column name. For an INSERT, UPDATE, or DELETE it is an empty array unless the statement carries a RETURNING clause. The generic is a compile-time assertion only — it is not validated against the columns the query actually returns.
raw(options?)
Return rows as arrays of values instead of objects, for callers that already know the column order or that feed a columnar consumer. The rows are converted after they arrive, so this is a shape convenience, not a smaller response.
{ columnNames: true } prepends one row of column names. An empty result set returns [] with no header row, so check the length before treating the first entry as headers. Rows are converted from the same objects all() returns, so two result columns with the same name collapse into one — alias them (SELECT a.id AS a_id, b.id AS b_id) when joining.
batch(statements)
Run several prepared statements as one atomic unit. Results come back in the order the statements were passed.
UNIQUE constraint leaves the table exactly as it was.
Every statement must come from the same database’s prepare(). Passing a statement built by a different binding throws env.<DB>.batch() accepts only statements created by this database's prepare().
batch() is the transaction primitive for parameterized, all-or-nothing writes: explicit BEGIN is rejected, so a transaction cannot be opened by hand.
exec(query)
Run a raw SQL script. Takes no bound parameters and returns no rows.
count is the number of statements in the script. Passing bind arguments throws env.<DB>.exec() takes no bound params — use prepare().bind() for parameterized statements. Use exec for schema and one-shot maintenance work, and prepare().bind() for anything that takes user input.
A script is atomic: if any statement in it fails, every statement in that script rolls back. A script that creates a table, inserts a row, and then hits an error leaves no table behind. So exec() either applies a whole script or changes nothing.
Errors
Every failure — syntax error, missing table, constraint violation, bad bind — rejects with a plainError. There is no typed error class, no cause chain, and no error-code taxonomy equivalent to D1’s D1_ERROR / D1_TYPE_ERROR / D1_COLUMN_NOTFOUND.
error.message is a long transport string with the SQLite message embedded near the end:
Related
- Overview — the SQL Databases Runtime API surface at a glance
- Quick Start — declare the binding and type it
- How SQL Databases Work — one primary per database, and where the data lives
- Migrations — versioned schema changes from the CLI
- Limits — statement size, parameter, and duration ceilings
- Stateful Actor SQL — the per-actor embedded SQLite at
ctx.storage.sql, private to one instance and synchronous
CLI
Source: https://developers.telnyx.com/docs/edge-compute/sqldb/cli.mdManage SQL databases using the
telnyx-edge CLI. Every command in this family reaches a
database through the same primary as the env binding, so the CLI is not a second writer —
SQL run from a terminal and SQL run from a deployed function are serialized together.
The storage sqldb family requires CLI v0.3.0 or newer — the first release to carry any SQL
surface. On v0.2.5 and earlier telnyx-edge storage sqldb fails as an unknown command. Check
with telnyx-edge --version.
Database Management
create returns immediately with status: pending; provisioning finishes in the background,
typically in 2 to 11 seconds:
get until the status is provision_ok:
list paginates, newest first:
delete takes effect immediately and asks nothing first — there is no confirmation prompt
and no --yes flag:
get and any query against that id return 404.
Delete removes the database record and makes the data unreachable through every path; it is
not a guaranteed erase of the stored bytes, so it does not satisfy a data-destruction
requirement on its own. It also does not touch functions that still declare the id in a
[storage.sqldb.<NAME>] block: those functions stay deployed and their queries start failing.
Remove the block and redeploy them.
list Flags
get and delete take the database id only. execute and the migrations subcommands
accept either the id or the database name — see Addressing a Database.
Running SQL
execute runs SQL against a database without deploying anything — to create a schema, load
seed data, or inspect what a function wrote.
SELECT list, and a SQL
NULL renders as NULL. To learn a new row’s id, or
to count the rows a write touched, add RETURNING to the statement and count the rows it
prints. SELECT changes() in a separate call reads a counter on a connection shared with every
other caller of the database, so another write can land in between.
--json prints the raw result object instead of a table — on stderr in the current CLI, so
scripts should capture it with 2>&1 >/dev/null rather than piping stdout. results holds the
rows and is the only field the service returns — a statement that produces no rows prints {}:
BLOB columns cross this boundary as a JSON array of byte values — a three-byte blob comes back
as [9, 8, 7], not base64 and not an object. Decode it as bytes on the client. Writing binary
this way means a SQL hex literal (X'090807'), since this path has no parameter binding; from a
function, bind an ArrayBuffer instead.
execute Flags
--remote is mandatory. Omitting it fails with --remote is required before any network
call: there is no local SQLite emulation to run against, and no --local flag.
The SQL text is submitted verbatim — the CLI never splits statements itself. A script may hold
several ;-separated statements; all of them run in order, and the rows returned are the
last statement’s rows. The whole script must fit under the ~4 MiB script ceiling
(see Limits).
A database that has not finished provisioning rejects execute with
409 — Database is not ready (status: pending). Wait for provision_ok.
Migrations
Versioned schema changes live in numbered.sql files and are tracked in a
sqldb_migrations table inside the database itself.
migrations Flags
The full workflow — file naming, the ledger table, and what happens when a migration fails
midway — is covered in Migrations.
Generating Types
telnyx-edge types reads the manifest and writes telnyx-env.d.ts, so env.<BINDING> is
typed at compile time. It runs fully offline — no authentication, no network.
id must be a well-formed UUID
(inline creation and binding by name are rejected), and @telnyx/edge-runtime in
package.json must be 0.9.0 or newer — the first version that exports SqlDatabase.
An older pin fails with
@telnyx/edge-runtime <declared version> does not export SqlDatabase (requires >= 0.9.0),
quoting the range exactly as package.json declares it.
Addressing a Database
execute, migrations list, and migrations apply take a <database> argument that
resolves either way:
- An exact id match always wins.
- A name resolves when exactly one database in the organization carries it.
- A name shared by several databases errors with
Multiple SQL databases are named "..."— pass the id instead. - No match errors with
No SQL database found matching "...".
get and delete do not resolve names; they take the id.
Related
- Quick Start — create a database, bind it, query it
- Migrations — versioned schema changes and the
sqldb_migrationsledger - Limits — request body size, query duration, and what is not enforced
SqlDatabase— theenvbinding surface- CLI Reference — every
telnyx-edgecommand
Limits
Source: https://developers.telnyx.com/docs/edge-compute/sqldb/limits.md
Limits
The ~4 MiB script ceiling covers the whole SQL script sent over the REST query path — which
is also what
telnyx-edge storage sqldb execute --file and migrations apply use. The service
advertises an 8 MiB gate and returns the 413 past it, but the transport underneath rejects
anything over roughly 4 MiB (4,194,304 bytes, less a few hundred bytes of envelope) with a 422
whose detail ends in stream too large — so ~4 MiB is the number to plan against, and a large
seed file needs splitting across several calls.
Result sets are capped at the same ~4 MiB, per statement. A query whose rows exceed it fails
with a ResourceExhausted … trying to send message larger than max error, through REST and
through the env binding alike. Only shipping the rows is capped — computing over the same data
server-side (length(), aggregates) is fine — so page through large reads instead of selecting
them whole.
The 2 MiB cap applies only to values passed through .bind(). A value produced inside SQL
is never bound and never checked, which is why randomblob(100000000) stores without
complaint. Keep anything approaching either figure — images, archives, model weights — in
object storage and bind the key instead.
Database size and bound-value size come from the SQLite storage engine that SQL Databases share
with per-actor SQLite, so the same
numbers apply on both surfaces.
Query Performance
A database is served by one primary, and statements run one at a time. A long statement delays everything else queued against that database, so the useful discipline is keeping individual statements short.
Add indexes on the columns you filter and order by, page through large result sets instead of
selecting them whole, and maintain a summary table on write rather than aggregating across
millions of rows on read. Long analytical scans belong in a warehouse, not here.
Related
SqlDatabase— the binding surface: methods, type conversion, and errors- How SQL Databases Work — one primary per database
- Migrations — versioned schema changes
- CLI —
execute,migrations, and database management - Stateful Actor SQL — the separate per-actor SQLite database
- Edge Compute Limits — function request timeout, memory, and body sizes