Table of Contents
- API Reference
- stdb (stdb/init.lua)
- Mistakes that raise
- stdb.builder(opts?)
- Context (ctx)
- Connection methods
- Table accessors
- stdb.query
- Subscription handle
- Subscription builder handle
- Call handle
- stdb.cache
- stdb.connection
- stdb.auth
- stdb.await
- stdb.schema and stdb.protocol
- Getting a schema without leaving Lua
- Project ▸ SpacetimeDB: Regenerate schema
- Special types and their units
- stdb.bsatn and stdb.protocol_v2
API Reference
stdb (stdb/init.lua)
| Function | Returns | Notes |
|---|---|---|
stdb.connect(opts) |
connection | Binds extension-websocket; reconnects unless reconnect = false |
stdb.url(base_url, database) |
string | <base_url>/v1/database/<db>/subscribe. base_url must carry the scheme, as in ws://127.0.0.1:3000, because nothing here adds one |
stdb.with_token(url, token) |
string | Appends ?token=; what HTML5 uses instead of a header |
stdb.is_html5() |
boolean | Platform check behind the transport choice |
stdb.sys_storage(name?) |
{ load, save } |
Identity storage via sys.save; name picks the slot |
stdb.http_seam() |
{ post, get } |
HTTP over http.request. This is the seam stdb.auth posts through and stdb.schema.fetch gets through. post(url, headers, body, cb) and get(url, cb) both call back (status, body); a status of 0 means nothing answered, and every HTTP status including 4xx/5xx arrives as itself. There is no err argument, because status 0 is Defold's own signal, so there is one way to say it rather than two. get takes no headers, because nothing that uses it sends any |
stdb.SUBPROTOCOL |
string | v2.bsatn.spacetimedb; the one wire this SDK speaks, and what every socket negotiates |
stdb.protocol, .cache, .schema, .connection, .auth, .query |
module | The core, re-exported. stdb.protocol is stdb/protocol_v2.lua, the BSATN codec, and stdb.query is the typed query builder, so neither needs a require of its own. See stdb.connection for the transport seam, stdb.auth for the credential exchange and stdb.query for the builder |
stdb.await(fn, ...), stdb.await_event(db, type) |
varies | Coroutine mode beside the callbacks. See stdb.await |
connect options:
| Option | Default | Meaning |
|---|---|---|
url |
none | Required; build it with stdb.url, which raises on a base URL carrying no ws:// or wss:// scheme |
schema |
{} |
The documented form is schema = gen, the whole generated module. connect recognises a module by its section set (tables, typespace, reducers, procedures all present) and takes the other three off it, so one option covers what four used to. A map from stdb.schema.new(gen.tables) is also accepted, and then typespace/reducers/procedures carry the rest. Column order is the BSATN field order and pairs each column with its type. Carrying types is what makes a table decodable at all, since a binary row carries no column names, so an entry without types is skipped and reported rather than decoded. See Row value types |
typespace |
none | The generated gen.typespace. Only sum_<Name> / product_<Name> columns need it. Given explicitly it wins over the section read off schema = gen |
reducers / procedures |
none | The generated gen.reducers / gen.procedures. Required to call one: a BSATN argument tuple is encoded against the parameter's declared type. Given explicitly they win over schema = gen, same as typespace. All four sections must come from the same generated module, since mixing generations decodes rows against columns the server no longer has |
on_event |
no-op | Receives every event in on_event events below |
token |
none | Supply your own; otherwise the server issues one |
reconnect |
true |
false to manage reconnection yourself |
backoff_base / backoff_cap |
1 / 30 |
Seconds; doubling, then multiplied by a deterministic per-client spread of 50% to 99.95% (see connection.backoff_spread) |
storage |
stdb.sys_storage() |
false disables identity persistence |
confirmed_reads |
nil |
Tri-state. nil sends nothing and the server's own default applies (true), while true/false send ?confirmed=true/false (hold updates until durable). So confirmed reads are on unless you pass false. See The confirmed_reads tri-state |
frame_budget |
nil |
Seconds. Spends at most this long per engine frame decoding rows and resumes on the next, so a large first subscription costs several frames instead of one hitch. Needs socket.gettime or clock; raises if the value is not a positive number, and raises rather than degrading if no clock is in reach. See Spreading a large apply across frames |
clock |
socket.gettime |
Wall-clock seam returning seconds. LuaSocket is compiled into both Defold tiers, so a game needs nothing here; it exists for tests and for a host without one. Read by frame_budget slicing and by the round-trip timing behind db:latency(), which degrades to os.clock when neither is available |
inflate |
the ambient zlib.inflate |
A gunzip for the compressed frames the server sends above 1KiB. Defaults to the engine's builtin, so a Defold game needs nothing here; injected mainly to keep the codec runnable outside an engine. Whether one is in reach decides the connect URL. It is ?compression=Gzip when one is, ?compression=None when none is, never omitted, since the server's own default is brotli and no Defold build can decode that. Needs Defold 1.12.4 or later, where zlib.inflate learned to read a gzip wrapper |
on_event events
on_event(evt) is the whole event stream and evt.type is one of the
following. The payload column names the keys that event carries besides type.
evt.type |
payload |
|---|---|
connected |
identity, token. The socket is up and usable |
reconnected |
identity, subscriptions (how many sets were replayed) |
reconnecting |
attempt, delay |
disconnected |
code, the socket close code. There is no reason key |
socket_error |
error |
subscribed |
request_id, query_id, counts |
unsubscribed |
request_id, query_id, counts |
subscription_failed |
error, request_id, query_id |
transaction |
counts, message |
event |
table, row. One row of an event table, not "an event" |
reducer_failed |
error, message |
procedure_result |
value, message, request_id |
procedure_failed |
error, message, request_id |
undescribed |
table, dropped, reason. Rows the schema could not decode |
unhandled |
tag, message. A frame this SDK has no handler for |
Two more ride the one-off query path, correlated on request_id and emitted
whether or not they matched a request this client sent. They are one_off_result
(tables, matched) and one_off_failed (error, matched). An answer
nobody is waiting for is the loudest symptom of a misrouted id, so they are not
suppressed. See One-off query rows.
undescribed is the one a game is most likely to need and least likely to
expect. It fires when a subscribed relation is not in the schema, which is what
a stale generated module looks like from here. Every other symptom of that is an
empty table.
The confirmed_reads tri-state
confirmed_reads is tri-state because an absent parameter is not the same
request as an explicit one. An explicit true or false is honoured as sent.
nil sends nothing and the server resolves it to true on this wire, holding
subscription updates until the transaction offset they were computed from is
confirmed durable. That is the server's rule rather than this SDK's, and both
the default and both overrides are pinned by an upstream test. See Where the
confirmed_reads default comes
from for the
resolving function and the test that fixes it.
So confirmed reads are on by default and the thing to reach for is the opt-out.
confirmed_reads = false gets subscription updates delivered immediately,
without the durability wait. That is the same posture every official
SpacetimeDB SDK takes. None of them sends the parameter unless asked, and each
exposes the explicit false. Pass the option explicitly to pin it, and
leave it nil only when you mean "whatever the server's own default is". That
is also why the option is not a boolean defaulting to false. Sending
confirmed=false for a caller who never asked is a different request than the
one this SDK has always made.
The tri-state is observable either way. Absent, true and false all connect,
and anything else is refused at the handshake. ?confirmed=banana answers 400
with a 77-byte body reading
"Failed to deserialize query string: provided string was not true or
false", which names the two values it accepts rather than the one you sent,
and does not name the parameter that was wrong.
Mistakes that raise
Four things that used to be silent now raise, because each one otherwise looked like a working connection with missing data. The message names the option or the fix rather than only the symptom.
| What you did | What you get |
|---|---|
Read a table the schema does not declare, db.tables.mesage |
stdb: no such table "mesage"; known: message, user (sorted, comma-joined). With no schema passed at all it names the option instead: stdb: no tables are known, so db.tables.mesage does not exist - pass schema = gen to stdb.connect. Before, this answered nil and failed a line later at the typo's first use |
| Call a reducer or procedure with that section missing | stdb: no reducers are known, so "send_message" cannot be called - pass reducers = gen.reducers to stdb.connect (or schema = gen). An empty known-list means the section was never passed, not that the module declares nothing, so the message names the option. A section that IS there and lacks the name raises stdb: no such reducer "sned_message"; known: ... |
Build a URL from a base with no scheme, stdb.url("127.0.0.1:3000", "chat") |
stdb.url: base_url needs a ws:// or wss:// scheme, got 127.0.0.1:3000. Concatenating without one yields a URL that fails inside the engine's websocket naming neither this function nor the missing scheme, and every Defold http.request example uses a bare host, so it is an easy omission |
| Decode a row against a schema the server has moved past | stdb.protocol_v2: decode_typed_bsatn: 8 byte(s) left after decoding a row of 3 column(s) - the schema is behind the server, regenerate the module. A column added at the end used to decode every row "fine" and drop the new field forever. Cursor exhaustion is the only structural self-check BSATN admits, so this is where a stale generated module becomes visible. Regenerate it |
Two neighbouring cases deliberately do not raise. A relation the schema cannot
describe at all is reported through the undescribed event and
Subscription.undescribed, because a raise there would abort the frame's table
loop and drop the rows of every relation you did describe (see Relations your
schema does not
describe). A ServerMessage
tag this SDK has no handler for becomes an unhandled event and leaves the
socket intact.
stdb.builder(opts?)
Fluent, official-shaped sugar over stdb.connect(opts). A thin translation,
not a second implementation. It duplicates neither connect's validation nor
its logic, and build() calls connect exactly once.
local db = stdb.builder()
:with_url("ws://127.0.0.1:3000")
:with_database("chat")
:with_token(saved_token) -- optional
:with_schema(gen) -- the whole generated module
:on_connect(function(ctx, identity, token) end)
:on_connect_error(function(ctx, err) end)
:on_disconnect(function(ctx, err) end)
:build()
opts is optional and seeds the connect options the fluent setters do not
cover, so a builder user never has to fall back to raw connect to reach one
of them (and never loses the three lifecycle callbacks by doing so). The seed
is copied, so with_token/with_schema write into the builder's own table and
not into yours, and build() passes it through verbatim. An on_event in the
seed keeps firing first, ahead of the lifecycle callbacks below.
local db = stdb.builder({ frame_budget = 0.004, reconnect = false })
:with_url("ws://127.0.0.1:3000")
:with_database("chat")
:with_schema(gen)
:build()
| Method | Notes |
|---|---|
builder:with_url(base_url) |
Scheme-qualified origin, the same base_url stdb.url would otherwise compose from |
builder:with_database(database) |
Paired with with_url into stdb.url(base_url, database) |
builder:with_token(token) |
Sets opts.token |
builder:with_schema(gen) |
Passes the whole generated module straight through as connect's own schema option, which reads typespace, reducers and procedures off it. One assignment rather than four, and one place that knows the module's shape |
builder:on_connect(fn) |
fn(ctx, identity, token) |
builder:on_connect_error(fn) |
fn(ctx, err) |
builder:on_disconnect(fn) |
fn(ctx, err) |
builder:build() |
Raises "stdb.builder: with_url(...) is required" / "...with_database(...) is required" if either was never called; otherwise calls stdb.connect(opts) once and returns its db |
The three lifecycle callbacks are not a new wire signal. They are this SDK's
own derivation from the on_event stream connect already emits. There is
no separate frame for "this is a fresh connect" versus "this is a reconnect":
on_connect(ctx, identity, token)fires on"connected"and"reconnected".on_connect_error(ctx, err)fires on a"socket_error"seen before the first successful connect.on_disconnect(ctx, err)fires on a"socket_error"or"disconnected"seen after one. That mirrors the official SDKs' own split between a connection that never came up and one that dropped after it did.
This is additive over connect. Any on_event a caller reaches through
connect's own opts keeps firing first, and db:subscribe_query and every
other connection method work exactly as if stdb.connect had been called directly.
Context (ctx)
One ctx per connection, built once alongside it, never per callback. It is
the first argument to every table callback
(on_insert/on_update/on_delete), every stdb.builder()
lifecycle callback (on_connect/on_connect_error/on_disconnect), and
every subscription builder callback
(on_applied/on_error).
| Member | Notes |
|---|---|
ctx.db |
The table accessor map. db.tables, db.db and ctx.db are one object under three names, not copies, so a row read or a callback registered through one is visible through the others. ctx.db.tables is therefore the one mistake the redundancy costs, and it raises naming the hop and listing the real tables rather than answering nil |
ctx.reducers |
Same table as db.reducers |
ctx.procedures |
Same table as db.procedures |
ctx.event |
The tagged cause of the callback batch in progress, { tag, ... }; nil outside one. Set immediately before the batch it caused and read synchronously inside that same call. Nothing here yields mid-dispatch, so there is nothing to race |
ctx:is_active() |
not the connection being closed; true until db:close() / db:disconnect() |
ctx:disconnect() |
Calls db:disconnect() (= db:close()) |
ctx:subscription_builder() |
Calls db:subscription_builder(). See Subscription builder handle |
ctx.event.tag is one of "reducer", "subscribe_applied",
"unsubscribe_applied", "transaction", "error". The extra fields depend
on the path that set it. A committed transaction carries timestamp (a
"reducer" cause adds outcome, and "reducer" from the row-dispatch path
adds request_id/query_id instead of timestamp for a subscribe or
unsubscribe cause); a callback wired through db:subscription_builder()
carries message (and error for "error"). None of that is worth memorizing
up front. Read ctx.event.tag first and branch on it before touching
anything else on the table.
This deviates from the design this shipped against. ctx.event.reducer is
nil for the "reducer" tag. The ReducerResult frame carries no reducer
name or arguments on the wire, only request_id/result/timestamp, so
there is nothing for this SDK to populate ctx.event.reducer = { name, args }
from. This is real shipped behavior rather than a bug, because a caller
reacting to its own reducer call already has the name, since it made the call.
Connection methods
Returns a request_id except where the table says otherwise. Callbacks are
(err, ...), and err is nil on success.
| Method | Callback | Notes |
|---|---|---|
db:subscribe_query(queries, cb) |
(err, msg) |
Returns a Subscription, not a request_id; droppable |
db:unsubscribe(query_id, cb) |
(err, msg) |
Takes a handle or a raw query_id; nil if already ended; server replies with unmatched rows as deletes |
db:subscription_builder() |
none | Returns a SubBuilder, fluent sugar over subscribe_query. Call :on_applied(fn):on_error(fn):subscribe(queries), or :subscribe_to_all_tables(). Wraps the same Subscription subscribe_query already returns, so a reconnect replay still re-fires on_applied. Purely additive; db:subscribe_query is unchanged and keeps working exactly as before |
db:call_reducer(name, args, cb) |
(err, update) |
Canonical snake_case name; returns a Call, not a request_id. The reducer's return value rides the message as raw BSATN on msg.result.ret_value, since the generated schema declares no reducer return type |
db:call_procedure(name, args, cb) |
(err, value, msg) |
value is decoded when the schema gives the procedure a returns type. gen.procedures gives it one, which is why passing it matters; without that entry the raw BSATN bytes go through instead. err is "connection lost" if the socket goes away first, db:close() included |
db.reducers.<name>(args, cb) / db.procedures.<name>(args, cb) |
(err, update) / (err, value, msg) |
Sugar over call_reducer / call_procedure by name. Built eagerly at connect, one closure per name in the generated reducers/procedures section, so an ordinary call touches no metatable. An unknown name falls through to a raise listing what the schema does have, stdb: no such reducer "<name>"; known: <sorted, comma-joined list> (or procedure). Argument count, not type, is checked against the schema before anything is sent. A mismatch raises stdb: <kind> "<name>" takes N argument(s) (<param names>), got M. db:call_reducer/db:call_procedure still take the string name and are unchanged |
db:one_off_query(sql, cb) |
(err, tables) |
No subscription, no cache write. Correlated on the request_id this client allocated, since the v2 wire echoes no id of its own, so several can be in flight; same "connection lost". Rows arrive as encoded byte strings, because a one-off projects a relation the schema need not describe and BSATN cannot be decoded without types. Decoding is the caller's, and a projection needs a def in its SELECT order, since decoding one against the table's def can swap columns silently. See One-off query rows |
db:close() |
none | Stops reconnecting, closes the socket, and settles everything still in flight |
db:disconnect() |
none | Alias of db:close(), the official spelling. Same teardown either way |
db:state() |
none | "connecting" | "connected" | "reconnecting" | "closed". Set at construction, on InitialConnection (first connect and every reconnect), and by the reconnect scheduler and close(). "closed" is terminal: frames arriving after it are discarded rather than handled, so nothing moves the state off it |
db:is_connected() |
none | db:state() == "connected" |
db:latency() |
none | Smoothed round-trip seconds (exponential moving average, weight 1/8 per sample, which is RFC 6298's SRTT smoothing), or nil before the first measurement. It is application-level, measured from the moment a request left this client to its correlated reply being decoded here, including server processing time. Every correlated request and response feeds it: reducer and procedure calls, subscribe/unsubscribe, one-off queries. A request settled by settle_pending's "connection lost" sweep never does, since no reply ever arrived for that one |
db:ping(cb) |
(err, seconds) |
Forces a fresh measurement with SELECT * FROM st_table WHERE table_name = '', the cheapest possible round trip against a system table every database has, filtered to match nothing. The callback is (err, seconds), the same shape every other async entry point here uses, so stdb.await(db.ping, db) returns the latency instead of raising it. No timer or polling loop is started, so call it on your own schedule |
db.cache |
none | The row cache; pass it to stdb.cache.* |
db.identity, db.token |
none | Set once IdentityToken lands |
db.connection_id |
none | This socket's ConnectionId, canonical hex, set alongside db.identity. A new one per socket, so it changes across a reconnect while the identity does not |
db.tables.<name> |
none | A table accessor per table in the schema; cannot collide with anything above. A name the schema does not declare raises, listing the ones it does; with no schema at all the message names the option you forgot (pass schema = gen to stdb.connect) |
db.db |
none | Same table as db.tables, the official-SDK-shaped name for the table accessor map. db.tables is unchanged and stays |
Everything else on db (send, on_event, pending, subscriptions,
query_sets, next_request_id, next_query_id, storage and
reconnect_pending) is internal and not covered by the 1.0 freeze. They are
populated and reachable, because Lua has no private fields, but they may change
in a patch release. The list above is the whole of what will not.
Table accessors
db.tables.message is one of these, for every table the schema declares. See
Per-table callbacks for the dispatch and deregistration
rules, and why they are namespaced.
Index with brackets for a table name that is not a valid Lua identifier, as in
db.tables["my-table"], db.tables["2fast"], db.tables["end"]. Dot access
would not reach those. db.tables.2fast is a syntax error, and
db.tables.my-table compiles (Lua reads it as db.tables.my - table) and
fails at runtime with an arithmetic error. Server modules choose these names,
so the bracket form is the general one.
| Member | Notes |
|---|---|
t.name |
The table name |
t.def |
Its schema entry, holding columns, types, pk, unique and indexes as declared |
t.conn |
The connection that owns the accessor. A back-reference the SDK reads to reach the cache; a consumer already holds db |
t:on_insert(fn) |
fn(ctx, row), once per inserted row. ctx is the shared connection context; ctx.event the tagged cause of this batch. Returns t |
t:on_update(fn) |
fn(ctx, old, new), once per changed row. Raises on a table with no pk. Returns t |
t:on_delete(fn) |
fn(ctx, row), once per deleted row, with the row as the cache last held it. Returns t |
t:off_insert(fn), t:off_update(fn), t:off_delete(fn) |
Detach one registration, the first match. Returns t |
t:remove_on_insert(fn), t:remove_on_update(fn), t:remove_on_delete(fn) |
The official-SDK spelling, aliases of off_insert/off_update/off_delete, the same function under two names. Returns t |
t:iter() |
Iterator over the cached rows |
t:count() |
Row count |
t:find(column, value) |
One row by a single-column unique constraint, pk included; nil on a miss, raises for a column under no unique constraint. Delegates to cache.find, so an identity column resolves by the hex string |
stdb.query
Re-exported on the facade as stdb.query, so there is no separate require.
Typed query builder that compiles to the same subscription SQL subset
subscribe_query accepts as a string. A Query is a tostring-able value that
db:subscribe_query recognises alongside plain strings by duck-typing on
__tostring, so the two forms mix freely in one call. Pure Lua, no engine
dependency.
local tables = stdb.query.tables(gen) -- gen = the same generated module connect takes
tables.message:where(function(r) return r.sender:eq("yuris") end)
db:subscribe_query({ tables.message:where(function(r) return r.id:gt(10) end) })
A hand-written SQL string is shorter and stays perfectly fine for a constant query. The builder earns its keep when a filter value comes from the player, because it is the only form that quotes that value for you. A name, a chat search term or a room id typed into a text field is escaped on the way into the SQL, where string concatenation hands the server whatever the player wrote. It also refuses an unknown table or column, and a value whose Lua type does not match the column's declared tag, at the callsite instead of at the server.
| Function | Returns | Notes |
|---|---|---|
query.tables(gen) |
{ [table name] = TableHandle } |
One handle per relation in gen.tables (tables and views alike). Indexing a name the schema does not have raises, listing the ones it does |
query.all(list) |
Predicate |
ANDs every predicate in list. Raises on an empty list |
query.any(list) |
Predicate |
ORs every predicate in list. Raises on an empty list |
query.and_(p, q, ...) |
Predicate |
Same as query.all, varargs instead of a list, and the official SDKs' own spelling (and/or there; the trailing underscore is only because those are Lua keywords) |
query.or_(p, q, ...) |
Predicate |
Same as query.any, varargs |
query.not_(p) |
Predicate |
Inverts p. The subscription grammar has no NOT token, so this pushes the negation down structurally instead. A leaf comparison flips its own operator (eq↔ne, lt↔gte, lte↔gt), and AND/OR invert by De Morgan's laws, recursively, so it covers a predicate tree of any depth rather than only a single comparison |
TableHandle member |
Notes |
|---|---|
h:where(fn) |
fn(r) receives a row proxy, where r.col resolves against the table's declared columns and anything else raises, naming the real ones. It must return a Predicate. Returns a Query, and that is terminal, since the SQL is computed once here |
h:left_semijoin(other, lcol, rcol) |
Join on self.lcol = other.rcol, keeping THIS table's rows, so it renders SELECT "self".* FROM "self" JOIN "other" ON .... Both columns must be indexed (pk, unique, or any index), and a missing index raises naming which side lacks it, before the server ever sees the query. Returns a JoinBuilder, which itself has :where(fn), where fn(l, r) receives one row proxy per table, left then right |
h:right_semijoin(other, lcol, rcol) |
Same join, keeping the OTHER table's rows instead. "left"/"right" name the surviving side, not a SQL LEFT/RIGHT JOIN; both always render as one inner JOIN |
Each Column method on r.col returns a Predicate leaf. They are :eq(v), :ne(v), :lt(v), :lte(v), :gt(v) and :gte(v), the official SDKs' own names rather than le/ge. Predicate has :and_(other) and :or_(other); the module-level and_/or_/not_ above cover the rest.
A built Query/JoinBuilder is a value. tostring(q) gives the compiled SQL,
and it never changes after where/left_semijoin returns it.
What it refuses, and why:
- An unknown table or column. The message lists the ones the schema does have.
- A comparison value whose Lua type does not match the column's declared tag
(
flag:eq("true")against aboolcolumn, say). - A
u64/i64/i128/u128/i256/u256/timestamp/time_durationcolumn compared against a Lua number above2^53. Pass a decimal string instead, the same rule the rest of this SDK applies to wide integers. - An
identity/connection_id/uuid/byte-vector column compared against anything that is not0x-prefixed hex text. - A join column that is not a column of its table, or that carries no index.
- Joining a table to itself.
- Combining predicates from two different tables' row proxies with
and_/or_. A semijoin'sonmust stay just the join equality; move the rest towhere.
Every identifier (table and column names, off the trusted schema) is
wrapped in "..." with embedded " doubled. Every string value, which comes
from game code, is wrapped in '...' with embedded ' doubled. That is what stands
between a caller's data and SQL injection, and it is exercised by a dedicated
test (... = ''' OR 1=1 --' posted to a live server and accepted as an inert
0-row filter, not a second predicate). Booleans render true/false; hex
values pass through verbatim as 0x...; integers render as plain decimal;
floats always carry a . or exponent, never a bare integer the grammar could
read as an INTEGER literal.
The subscription grammar supports no column-list or aggregate projection, no
ORDER BY or LIMIT, no more than two tables or more than one JOIN, and no
arithmetic in WHERE, so none of that is available here.
Subscription handle
Returned by db:subscribe_query. See Handles for the state machine.
| Member | Notes |
|---|---|
sub.query_id |
The raw id; still accepted by db:unsubscribe |
sub.queries |
The query strings this set watches, exactly as handed to subscribe_query, and what a reconnect replays |
sub.conn |
The connection that owns the set. A back-reference the SDK reads to send the drop; a consumer already holds db |
sub.state |
pending → sent → applied → ended | error; the last two are terminal |
sub.error |
The server's rejection message, once state == "error" |
sub.message |
The frame that produced the current state, either the applied payload or the error frame |
sub.undescribed |
The relations whose rows the applying frame DROPPED, as { { table, dropped, reason } }, nil when nothing was skipped, and rewritten on every apply. This is how a view subscriber learns the set applied but its rows did not, without an on_event. See Relations your schema does not describe |
sub:is_active() |
state == "applied"; false across a reconnect gap |
sub:is_ended() |
ended or error |
sub:on_applied(fn) |
fn(msg); fires again on every reconnect replay. Returns sub |
sub:off_applied(fn) |
Detaches one on_applied registration. Returns sub |
sub:on_error(fn) |
fn(err, msg); terminal. Returns sub |
sub:unsubscribe(cb) |
Returns a request_id, or nil if already ended, and cb fires either way |
Subscription builder handle
Returned by db:subscription_builder(), fluent sugar over db:subscribe_query,
wrapping the same Subscription it already returns, so a
reconnect replay still re-fires on_applied. Purely additive; db:subscribe_query
keeps working exactly as it always has.
db:subscription_builder()
:on_applied(function(ctx) end)
:on_error(function(ctx, err) end)
:subscribe({ "SELECT * FROM message" })
db:subscription_builder():subscribe_to_all_tables()
SubBuilder is what subscription_builder() returns, before subscribe.
| Member | Notes |
|---|---|
builder:on_applied(fn) |
fn(ctx), wired onto the real Subscription:on_applied once subscribe/subscribe_to_all_tables is called, including reconnect replay. Returns builder |
builder:on_error(fn) |
fn(ctx, err), wired onto Subscription:on_error. Returns builder |
builder:subscribe(queries) |
Subscribes to a list of query strings (or anything db:subscribe_query already accepts). Returns a SubHandle (below) |
builder:subscribe_to_all_tables() |
One SELECT * FROM <t> per relation the schema declares, sent as a single batch, literally what the official SDK's same-named method does. Returns a SubHandle |
Registering on_applied/on_error takes effect only for a subscribe/
subscribe_to_all_tables call made afterward; wire them before subscribing.
SubHandle is what subscribe/subscribe_to_all_tables returns.
| Member | Notes |
|---|---|
handle:is_active() |
Delegates to the wrapped Subscription:is_active() |
handle:is_ended() |
Delegates to Subscription:is_ended() |
handle:unsubscribe(callback) |
Raises "stdb: subscription already unsubscribed (or never applied)" on an already-ended set. This is where this handle differs from the plain Subscription it wraps, since sub:unsubscribe() there stays a no-op returning nil with cb still firing. Otherwise delegates to Subscription:unsubscribe(callback) |
handle:unsubscribe_then(callback) |
callback(ctx) fires once the drop settles, this SDK's callback shape for the official SDKs' unsubscribe promise. Also raises up front on an already-ended set, for the same reason unsubscribe does, with its own wording, "stdb: subscription already ended, nothing left to unsubscribe" |
Call handle
Returned by db:call_reducer.
| Member | Notes |
|---|---|
call.request_id |
The id the reply correlates on |
call.state |
pending → ok | error; the last two are terminal |
call.error |
The reducer's message, or "connection lost" if the socket died |
call.message |
The ReducerResult that settled it |
call:is_pending() |
The transaction has not landed |
call:on_ok(fn) |
fn(update). Returns call |
call:on_error(fn) |
fn(err, update), the reducer failed. Returns call |
Registering for a state that has already arrived fires immediately.
Energy exhaustion has no state of its own. The ReducerOutcome this wire
carries has no OutOfEnergy variant and there is no substitute signal, so an
exhausted account arrives as internal-error text and settles the call as an
error like any other host failure.
stdb.cache
Read it after events; the connection layer does the writing.
| Function | Returns |
|---|---|
cache.new(schema, typespace?) |
a fresh cache; stdb.connect calls this for you and hands it back as db.cache |
cache.iter(db.cache, table) |
iterator over rows |
cache.get(db.cache, table, key) |
one row by primary key, or nil |
cache.find(db.cache, table, column, value) |
one row by a single-column unique constraint, or nil |
cache.count(db.cache, table) |
row count |
cache.drop_all(db.cache) / cache.drop_table(db.cache, table) |
nothing |
cache.apply_database_update(db.cache, update) |
{ inserts, deletes, events }, plus undescribed when a relation had to be skipped. See Relations your schema does not describe |
cache.slice_hook |
Field, not a function, and nil by default. connect sets it while a frame_budget apply is in flight and clears it afterwards; it is called every 256 decoded rows. Documented because it is observable, not because it is an extension point. The only safe yield point in an apply is the row decode, so anything else calling it can leave a batch half-applied |
cache.row_decoder |
Field, not a function, and nil on a bare cache. The per-connection row decoder, taking (row_bytes, def, typespace). connection.new sets it to protocol_v2.decode_typed_bsatn. It is also why a table with no types is undecodable, reported as binary rows need generated types |
Tables declared without a pk are cached as append-only lists, and so is every
generated view, for which the schema endpoint reports none.
Row values follow Row value types, and so do the keys
get and find take. An Identity column is the 0x… hex string, Timestamp
and TimeDuration are microseconds as numbers, and ConnectionId and Uuid are
exact decimal digit strings. See Special types and their
units for the units and the ceilings. A schema
entry with no types is skipped whole and never decoded, so those are the only
shapes a cached row holds.
cache.find looks a row up by a unique column (a handle, slug, email,
account number or identity), where cache.get only takes the pk. It reads an
index the cache maintains per column named in the schema's unique list, so it
costs a hash lookup where iter would cost a scan of the table. Passing the pk
column is allowed and delegates to cache.get, so generated code can loop the one
unique list and needs no pk special case. A column with no unique constraint
raises, because returning nil would be indistinguishable from a miss and would hide a
lookup that can never succeed. unique is optional, so a hand-written schema without
it simply gets no index, and the pk stays the only key.
A unique identity column resolves by the identity string, the same value
db.identity holds, because the column decodes to the server's own hex text.
That is the "which row is me?" lookup every player table wants on connect. See
Identity-keyed tables.
stdb.connection
The pure core, with no engine, no socket and no timer. stdb.connect is a
transport wrapper around it. It owns extension-websocket, the reconnect timer
and sys.save, and drives seven of the eight functions below. Use them directly
to run this SDK on a transport extension-websocket does not cover, such as a
LuaSocket client, a test harness or another engine. stdb/init.lua is the worked
example of those seven; dispatch is the one it reaches only through handle_frame.
| Function | Notes |
|---|---|
connection.new(opts) |
{ send, inflate?, schema?, typespace?, reducers?, procedures?, storage?, on_event?, clock? } → the db the rest of this reference describes, with no socket attached. send(frame) must deliver a binary frame. schema here is the table-name map, not a whole generated module. The schema = gen shorthand is stdb.connect's, which splits the module before calling this. clock defaults to os.clock, which is enough for round-trip timing and is why this file needs no engine |
connection.set_send(db, fn) |
Installs the sender for a new socket, and settles everything the dead one still owed (settle_pending(db, true)). Call it before resubscribe_all |
db:handle_frame(bytes) |
Feed one received frame in, as the Lua string a binary frame arrives as. Decode plus dispatch, and the only place the two are joined. Raises on a malformed frame, and that is all a raise does. Nothing here or in stdb/init.lua closes the socket on one, so the next frame is handled normally |
connection.dispatch(db, msg) |
Dispatch one already-decoded ServerMessage, a plain Lua table. Split from handle_frame because decoding bytes and acting on a message are two jobs. Everything dispatch does is connection bookkeeping (pending correlation, handle settlement, cache application, event emission), and none of it cares that the wire was BSATN, which is what makes the whole server-to-client surface reachable without a socket or a codec, and is how the unit suite drives it. A tag this SDK does not know becomes an unhandled event, never a raise. Returns msg |
connection.resubscribe_all(db) |
Empties the cache and replays every live set under its original query_id, so caller-held handles survive the gap. Returns how many were replayed. Call it once per socket, or on_applied fires once per stacked entry |
connection.settle_pending(db, replay) |
Settles every request still in flight on a socket that is gone. replay = false when nothing will reconnect. That is what stops a caller parking forever after a terminal close |
connection.backoff_delay(attempt, base, cap, spread?) |
The reconnect delay connect uses, with base doubling per 1-based attempt, capped at cap, both seconds. spread is an optional multiplier applied after the cap, so a capped delay is spread too; nil is an exact no-op. Pure, so a replacement transport can reconnect on the same schedule |
connection.backoff_spread(identity, attempt) |
The multiplier connect feeds backoff_delay, a deterministic value in [0.5, 1.0) derived from the identity, so clients scatter across the retry window instead of arriving in one instant, and every client's slot changes each attempt. There are 1000 distinct slots, so a large enough population does share them, and clients in one slot do arrive together, with 20,000 clients putting about 20 in the busiest slot. What the quantisation buys is the gap between neighbouring slots, 15ms at the 30s cap, so a herd arrives smeared across the window in 15ms steps instead of in one instant. It is nil with no identity at all, and connect passes the persisted identity before the first socket succeeds, so this is nil only for a client that has never connected and saved one. There is no RNG, because a library calling math.randomseed would clobber the game's own sequence, and an unseeded math.random gives every client the same spread, which is the stampede this prevents |
The request methods (subscribe_query, unsubscribe, call_reducer,
call_procedure, one_off_query) are the same functions
Connection methods documents as db:…. db is only
connection's own table reached through __index, so connection.subscribe_query(db, …)
and db:subscribe_query(…) are one call.
stdb.auth
| Function | Notes |
|---|---|
auth.exchange(opts, cb) |
Trades a provider credential for a SpacetimeDB token. cb(err, token) fires exactly once. See Authenticating with a provider for the options, the four error strings, and why the cached token is not validated |
Engine-dependent, like stdb/init.lua and unlike the rest of the core, because
it reads the timer global for its backoff and per-attempt timeout. Both timer and
http are injectable options, which is how the unit suite drives four attempts
and three backoffs without sleeping once.
stdb.await
stdb.await / stdb.await_event, re-exported on stdb, are a coroutine mode
beside the callbacks, not a replacement for them. They are additive, and nothing
about an existing callback's shape or timing changes.
coroutine.wrap(function()
local sub = stdb.await(db.subscribe_query, db, { "SELECT * FROM message" })
local update = stdb.await(db.call_reducer, db, "send_message", { "yuris", "hi" })
end)()
| Function | Notes |
|---|---|
stdb.await(fn, ...) |
Calls fn(..., callback) and yields the running coroutine until callback fires, following the (err, ...) shape every one-shot request callback in this SDK uses, namely call_reducer, subscribe_query, call_procedure, one_off_query, unsubscribe / Subscription:unsubscribe, and ping. A truthy err is error(err, 0) inside the coroutine rather than returned as a value a caller might forget to check; everything after err unpacks as await's return, so local value, msg = stdb.await(db.call_procedure, db, "get_score", {}). fn is called as a plain function with one callback appended, so a method call needs db passed explicitly (stdb.await(db.call_reducer, db, "send_message", {...})), while the accessor form captures it already (stdb.await(db.reducers.send_message, {...})). Called outside a coroutine it raises stdb.await: must be called inside a coroutine rather than parking with nothing left to resume it |
stdb.await_event(db, event_type) |
Yields until db.on_event next fires an event whose .type is event_type, then returns that event table. For the one async result with no callback to wrap, since stdb.connect returns synchronously and reports readiness through { type = "connected", ... } on on_event. Temporarily wraps db.on_event; every event is forwarded to whatever handler was already installed before the match check, so an existing opts.on_event consumer sees nothing different, and the wrapper detaches itself the moment it resolves. await_event(db, "connected") is special-cased against the connection's own state and returns immediately when the socket is already up, because "wait until connected, then resubscribe" reads this way after a reconnect has already landed, which is exactly when the next connected may never come |
await also wires the handle fn returned, when there is one, not just the
appended callback. That is what keeps two calls from parking forever. A
call_reducer whose socket dies mid-flight settles the Call and skips the
plain callback, so the news rides Call:on_error alone; a subscribe_query
re-issued by the reconnect replay is re-sent with a fresh no-op callback, so the
awaited one is never called again, while Subscription:on_applied re-fires on
every apply. Whichever surface answers first wins and the rest are inert, and
the settled on_applied closure is detached afterwards rather than left
attached for the life of the subscription.
A pcall cannot wrap a whole coroutine.wrap body that awaits. Lua 5.1 cannot
yield across pcall and HTML5 is plain Lua 5.1, so catch the failure around the
individual awaited step instead.
coroutine.wrap(function()
local ok, err = pcall(stdb.await, db.reducers.send_message, { "yuris", "hi" })
if not ok then print(err) end
end)()
Call:on_ok/Call:on_error and Subscription:on_applied/on_error are not
(err, ...)-shaped one-shots, since on_applied can refire after a reconnect
and the others split by outcome rather than resolving once, so await does not
wrap them as awaitables. The handle API is the idiom there, coroutine or not.
Any of these callbacks can fire synchronously, before coroutine.yield runs;
await/await_event detect that and return inline instead of resuming a
coroutine that never yielded.
stdb.schema and stdb.protocol
| Function | Notes |
|---|---|
schema.new(defs) |
Validates { [table] = { columns, types?, pk?, unique?, view? } }; raises on a pk or unique name that is not a column, on a types list whose length does not match columns, and on an empty columns list (a relation with no fields decodes every row to nothing) |
schema.fetch(opts, cb) |
Fetches the schema off a running server and hands back the generated module as a table. opts is { base_url, database, json?, http?, version? }; cb(err, gen). Detailed below |
schema.to_lua(gen, source?) |
gen serialised as a loadable -- GENERATED Lua module, ready to write to disk and require later. Detailed below |
typetags.build(decoded) |
The schema endpoint's decoded JSON to { tables, reducers, procedures, typespace }. require("stdb.typetags"); both functions above go through it, so a fetched schema and a written one cannot disagree |
stdb.protocol is stdb/protocol_v2.lua, the BSATN codec. Its functions are
stdb.protocol_v2 below.
Getting a schema without leaving Lua
A generated schema is not optional, because a BSATN row carries nothing that
names a column, so a table without types is skipped and reported rather than
decoded. There are two ways to get one, and both walk the same type tags through
stdb.typetags, so they cannot drift apart.
schema.fetch(opts, cb) reads the schema off a running server at runtime.
That is the development path, with no build step, no generated file to keep in
step with the module, and nothing to install.
stdb.schema.fetch({
base_url = "http://127.0.0.1:3000", -- required; HTTP, not the ws:// URL
database = "chat", -- required
}, function(err, gen)
if err then return end
local db = stdb.connect({
url = stdb.url("ws://127.0.0.1:3000", "chat"),
schema = gen,
})
end)
json is no longer an option you have to pass. It defaults to the engine's
json global, which every Defold build has, so it is needed only off-engine
(a unit test, another host) where there is no such global to default to.
http defaults to stdb.http_seam() and is the injection point a test replaces.
version defaults to 9, the schema version this SDK's type tags are written
against; it exists so a later server can be read without a release here.
Missing options are a callsite mistake and raise at level 2. The three messages are
stdb.schema: fetch needs a callback, stdb.schema: fetch needs base_url and database (an empty string counts as missing), and stdb.schema: fetch needs `json`, the engine's builtin (absent outside Defold - pass opts.json) when
there is neither an opts.json nor a json global with a decode. Everything
after that is a runtime condition and
reaches cb as a string instead, because a server that is not running is not a
mistake in the calling code.
err |
what happened |
|---|---|
no response from <url> |
status 0, so nothing answered. Wrong port, or the server is down |
schema fetch failed (<status>) |
any non-2xx. A 404 is usually a database name that was never published |
schema fetch decoded to nothing usable: <lua error> |
a 200 whose body is not a schema this SDK can walk |
fetch never raises from inside the callback path, so a server that goes away
costs you an err rather than a dead script.
schema.to_lua(gen, source?) turns the same table into a module you can
commit. That is the release path. Pin the schema at build time and a shipped game
neither depends on the server answering before its first connect nor pays a round
trip for something that last changed when the module was published.
local text = stdb.schema.to_lua(gen, "http://127.0.0.1:3000/v1/database/chat")
-- text starts `-- GENERATED by stdb.schema.to_lua from <source>`, then
-- `return { ... }`. Write it to my_schema.lua and `require("my_schema")`.
assert(loadstring(text))() -- round-trips to a table equal to gen
Keys come out sorted, so re-running it against an unchanged module produces an
unchanged file and a diff means the schema really moved. It is not byte-identical
to what dev/tools/gen-schema.sh writes, since that script is the repo's own drift
check and emits per-section comments this does not, but the table it loads to is
the same one, which is what a fixture test pins. Every line stays under 120
columns.
Project ▸ SpacetimeDB: Regenerate schema
The library ships one editor command, so the release path needs no terminal.
Defold picks up a dependency's editor scripts on Project ▸ Fetch Libraries, and
Project ▸ SpacetimeDB: Regenerate schema then fetches the schema, walks it
through the same stdb.typetags, and writes /my_schema.lua at your project
root. require("my_schema") reaches it, and the file's header names the URL it
came from.
game.project tells it where to look, under a [spacetimedb] category.
[spacetimedb]
base_url = http://127.0.0.1:3000
database = chat
database is required. There is no sane default for someone else's module name,
and guessing one used to generate a schema from the wrong database successfully
and silently, so the command now raises instead, with stdb: no database to generate from - add a [spacetimedb] category to game.project with database = <your module name>. base_url is optional and falls back to http://127.0.0.1:3000, which
is what a local spacetimedb start listens on and what
dev/tools/dev-server.sh serves. It must begin with http:// or https://;
anything else is refused rather than handed to curl.
The command shells out to curl, because the editor's own Lua runtime has no
HTTP client and no JSON module. macOS, Linux and Windows 10 and later all ship
curl; on a machine without it the runtime schema.fetch path above still works
and needs no external tool at all. A failure raises with the URL in the message,
either stdb: no schema from <url>. Is the server running, and is curl on PATH?
or stdb: <url> did not answer JSON, so the editor console says which server did
not answer rather than that something went wrong.
Special types and their units
The wire unit for time is microseconds. os.date("%H:%M", row.created_at) on
a raw Timestamp does not error. It prints a date about 56 million years out.
Divide by 1e6 first; there is no conversion helper.
A BSATN special type is a single-field product in SATS, so its bytes are the bare payload with no wrapper around them and one decoded value per column.
| Type | Decodes to | Note |
|---|---|---|
Identity |
"0x…", 64 lowercase hex digits |
The wire carries 32 little-endian bytes; the hex is big-endian, byte-for-byte the server's own to_hex |
ConnectionId |
exact decimal digit string | protocol.connection_id_to_hex(v) → "0x…", 32 digits, for anything human or server-facing |
Uuid |
exact decimal digit string | No canonical-text helper; the digits are the value |
Timestamp |
number, micros since the Unix epoch | |
TimeDuration |
number, micros | |
ScheduleAt |
{ tag = "Interval" | "Time", value = micros } |
An inline sum, so it has no typespace entry and the variant names come from this SDK |
connection_id_to_hex is a total value-level accessor. It returns nil for
any input it cannot interpret and never raises, and it validates rather than
passing through, so connection_id_to_hex("hello") is nil, not "hello".
Frame-level functions (decode_server, decode_typed_bsatn, encode_client)
raise instead, because a malformed frame is a protocol violation that should kill
the connection, whereas a wrong-shaped value is a caller mistake that should
surface at the caller. Callers check for nil.
An Option column decodes to the value or to nil. None is absent, and a Lua
table field holding nil IS absent, so Some(None) collapses into None.
One ceiling worth knowing comes from Lua 5.1 having no integer type. Integers are
exact in a double up to 2^53. As microseconds that is reached in 2255, so
current-era timestamps are exact; only the division to seconds loses anything,
and whole-second resolution survives it. Floor the seconds for os.date; compare
fractional seconds with a tolerance. The 128-bit types escape it, since a
ConnectionId or Uuid is read with u128_digits and arrives as exact decimal
text, never a rounded number, so the hex connection_id_to_hex renders is all 32
digits of the server's own. u64 is the one that does round above 2^53. See
Row value types.
stdb.bsatn and stdb.protocol_v2
The binary wire. stdb.protocol is protocol_v2, one table under two
names; bsatn is not re-exported, because nothing outside protocol_v2 needs
it. require("stdb.bsatn") and require("stdb.protocol_v2") to read or write
frames yourself. Both are pure, with no engine APIs and no JSON, and protocol_v2
depends only on bsatn.
Everything here raises on malformed input, which is the frame-level stance the wire forces. BSATN carries no widths, so a value the decoder cannot size leaves every later column unreadable.
stdb.bsatn, the primitive codec
| Function | Notes |
|---|---|
bsatn.reader(data) |
A cursor over a Lua string. Little-endian throughout |
bsatn.writer() |
Accumulates fragments; :build() joins them once |
| Reader method | Returns |
|---|---|
r:u8(), r:u16(), r:u32(), r:u64() |
number. u64 is exact to 2^53 and rounds above it, since Lua 5.1 has one number type, a double |
r:i8(), r:i16(), r:i32(), r:i64() |
number, two's complement |
r:u128_digits(), r:i128_digits() |
exact decimal digit string |
r:u256_hex(), r:i256_hex() |
minimal-width hex text, -0x when negative; zero is "0x0" |
r:f32(), r:f64() |
number; zero, subnormals, inf and NaN all handled |
r:string() |
u32 length, then the bytes |
r:raw(n) |
n bytes with no length prefix |
r:len(), r:tag(), r:bytes() |
u32 array count, u8 sum ordinal, and an alias of string, named for what they read |
r:remaining() |
bytes left |
| Writer method | Notes |
|---|---|
w:u8(v) … w:u64(v), w:i8(v) … w:i64(v) |
Signed writers are the unsigned ones, because two's complement is the same bytes |
w:f32(v), w:f64(v) |
f32 rounds half to even, IEEE-754's default; f64 is exact |
w:string(s), w:raw(s), w:len(n), w:tag(t), w:bytes(s) |
As the reader's counterparts |
w:build() |
The encoded string |
There are no 128- or 256-bit writers. No v2 client message carries one, and a reducer parameter of that width raises rather than shipping wrong bytes.
stdb.protocol_v2, the v2 message schema
| Function | Notes |
|---|---|
protocol_v2.decode_server(data, inflate) |
One server frame (the compression byte, then one BSATN ServerMessage, or a gzip stream of one) to { tag = "<VariantName>", ... }. Field names are the wire's own. inflate is a gunzip, passed in rather than reached for so the module stays engine-free; Defold's zlib.inflate is one. A gzip byte with no inflate raises, a brotli byte always raises, and so does an unknown ordinal. Rows come back as byte strings, undecoded |
protocol_v2.encode_client(msg, schema) |
{ tag = "CallReducer", ... } to a raw BSATN ClientMessage, with no compression byte, so send it as a binary frame. schema supplies typespace, reducers and procedures to encode args; an args that is already a string is taken as pre-encoded BSATN |
protocol_v2.decode_typed_bsatn(row_bytes, def, typespace) |
One BSATN row to a named Lua table, column by column against the generated def.types, and the cache.row_decoder every connection installs. def.types is not optional, because a binary row carries no field names, so a table without generated types cannot be decoded at all |
protocol_v2.decode_value_bsatn(r, tag, typespace) |
One value off a reader against one type tag. An unknown tag raises rather than passing through, because with no widths on the wire the reader could not find the next column either |
protocol_v2.decode_bytes(bytes, tag, typespace) |
One BSATN value out of a standalone byte string, such as a reducer return value or a procedure result, against one type tag. The bytes must hold exactly that one value, and a leftover raises, because it means the declared type is not the one the server encoded |
protocol_v2.decodable_tag(tag, typespace) |
Whether a def.types entry can be decoded from BSATN at all; a boolean for anything such an entry can hold, nil included. The cache asks this once per relation, so a column whose width cannot be derived (the generator emits result and unknown) skips and reports the table instead of raising out of every frame that touches it |
f32 is the one value shape worth a note. BSATN carries the four bytes of the
stored single, so 0.1 decodes to 0.10000000149011612, that single's exact
value, which is what the server stored. Only values that do not fit 24 mantissa
bits look surprising. Every other tag is pinned by dev/test/test_protocol_v2.lua,
which decodes frame 29 of the captured session through this codec and through
dev/test/crosscheck_v1.lua, a test-only JSON decoder of the same message
shipped with nothing, and asserts the two Lua values are equal. BSATN is opaque,
since a wrong type belief returns a plausible number instead of raising, so a
self-describing second opinion is what makes one visible. See Row value
types for the tag-by-tag surface and v2 over
BSATN for the envelope and the message map.
spacetimedb-defold
Using the SDK
Working on the SDK