spacetimedb_sdk 2.2.0
spacetimedb_sdk: ^2.2.0 copied to clipboard
Dart SDK for SpacetimeDB. v2 wire protocol, real-time typed tables, reactive primitives, optimistic updates, and opt-in offline storage for Flutter apps.
Changelog #
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
2.2.0 - 2026-06-27 #
Hardens the offline-first sync path: a configurable replay policy for the offline mutation queue, surfaced flush failures, append-only journal storage, and a cluster of reconnect-correctness fixes that stop offline edits being silently clobbered when a subscription rehydrates. All additive — existing consumers keep working unchanged.
Added #
OfflineQueuePolicy— opt-in control over how the offline mutation queue replays on reconnect. Configurable TTL (drop mutations older than a bound), queue size bounds, and anonBeforeReplayveto callback that lets the consumer inspect and reject queued mutations before they flush. Exported fromspacetimedb_sdk.dartandcodegen.dart. Defaults preserve prior behaviour (replay everything, no expiry).- Sync-failure surfacing on
SyncState. Flush rejections are now recorded as failure records onSyncStateinstead of being swallowed, with a configurable retention bound (also viaOfflineQueuePolicy). NewclearSyncErrors()to drain them; generated clients expose aclearSyncErrorspassthrough. - New exception type(s) on
lib/src/exceptions.dartfor offline-replay rejection paths.
Changed (non-breaking) #
- Append-only journal storage for the offline mutation queue. The JSON file backend no longer rewrites the entire queue file on every mutation — it appends to a journal, cutting write amplification for large or churny queues. Transparent to consumers; same
OfflineStoragecontract.
Fixed #
- Offline edits no longer clobbered on reconnect. Rows with pending offline mutations are protected from stale-snapshot overwrites when a subscription delivers a fresh server snapshot on reconnect (
5bc3898). - Subscription rehydrates via the awaited subscribe path on reconnect rather than racing the cache, so reconnect-time row state is correct (
b4bae9f). SubscribeAppliedcache clear scoped to the current query set — a multi-queryset client no longer wipes unrelated cached rows when one subscription re-applies (48b0a09).- Cache reconciled to committed server rows when an optimistic confirmation releases a key, eliminating a window where the local view diverged from the server (
1c638df).
Adds v3 WebSocket transport support (SpacetimeDB 2.2.0+) and unblocks Flutter web wasm builds. Both changes are additive — no consumer code changes required, no semver-breaking deltas.
Added #
- v3 WebSocket subprotocol negotiation. SDK now advertises
[v3.bsatn.spacetimedb, v2.bsatn.spacetimedb]and uses whichever the server picks. v3 servers (SpacetimeDB 2.2.0+, upstream PR #4761) negotiate v3 automatically; older servers fall back to v2 with no behavioural change. NewSpacetimeDbConnection.negotiatedProtocolgetter exposes the result. New public enumNegotiatedWsProtocol { v2, v3 }. - Inbound v3 frame batching. On v3, the server may pack multiple
ServerMessages into one WebSocket frame to cut per-message overhead.MessageDecoder.decodeAll(bytes)reads the compression tag once, decompresses once, then splits the payload into N messages and dispatches each in logical order. The single-messageMessageDecoder.decode(bytes)is preserved for v2 callers and asserts a single-message payload. - Outbound v3 frame batching (opportunistic). Same-microtask
send(...)calls coalesce into one frame on v3 — captures the realistic win of N synchronous subscribes during reconnect-resubscribe. Capped atConnectionConfig.maxV3OutboundFrameBytes(default 256 KiB, matching the TypeScript SDK); a queue exceeding the cap splits across multiple frames with aTimer(Duration.zero)yield between them so a backlog never starves the read path. NewConnectionConfig.outboundBatchingknob (OutboundBatchingPolicy.opportunisticdefault,disabledfor one-frame-per-call). On v2 or withdisabled, everysendis one frame unchanged. - Flutter web wasm support.
flutter build web --wasmno longer fails at first connection withUnsupportedError: No WebSocket implementation for this platform. The conditional-import gate inlib/src/connection/websocket.dartandplatform.dartmigrated fromdart.library.html(false under wasm) todart.library.js_interop(true under both JS and wasm).HtmlWebSocketChannelinweb_socket_channel3.x is already wasm-safe — no behavioural change for existing JS web builds. - Integration tests against a live 2.2.0+ server:
test/integration/v3_protocol_negotiation_test.dartverifies (a) server accepts v3, (b)[v3, v2]lands on v3, (c) v2 fallback regression guard, (d)SpacetimeDbConnection.negotiatedProtocol == v3end-to-end with first-frame decode.
Changed (non-breaking) #
- Renamed internal files
websocket_html.dart→websocket_web.dartandplatform_html.dart→platform_web.dart. Public surface unchanged — these files were never exported.
Compatibility #
- Server: v3 features require SpacetimeDB 2.2.0+. Older servers transparently negotiate v2.
- Flutter web: wasm builds now work; JS builds behave identically to 2.0.0.
- Test infrastructure: local development requires
spacetime version use >= 2.2.0for the v3 integration tests to land on v3 (otherwise the suite still passes via v2 fallback assertions).
2.0.0 - 2026-04-25 #
First release targeting the SpacetimeDB v2 WebSocket wire protocol (server 2.x). This is a hard cut. The SDK no longer speaks v1; if you need a v1 client, stay on spacetimedb_sdk 1.x.
This is a full rewrite of every message the SDK reads and writes, not a compatibility shim or a subprotocol flip. Every ClientMessage and ServerMessage has been re-encoded and re-decoded against the canonical upstream definitions in crates/client-api-messages/src/websocket/v2.rs. Removed message types are gone, not stubbed. Removed fields are deleted, not null-compat'd. Wire-format alignment was verified against live SpacetimeDB 2.x servers, not inferred from docs.
Added #
Uint8List? retValueonTransactionResult. v2 reducer calls returnReducerResultwith an optionalret_value: Bytespayload. Forward-compat for future typed reducer returns (today it'snullunless the server sends bytes). Zero-lengthOk.ret_valueandOkEmptyboth collapse toretValue: nullso consumers check one thing, not two.SendDroppedRowsflag onunsubscribe(). New signature:unsubscribe(int querySetId, {int requestId = 0, bool sendDroppedRows = false}). Whentrue, the server replies with anUnsubscribeAppliedcarrying the rows being dropped from this client's subscription set; the SDK dispatches those as delete events on the affected row streams. Defaultfalsepreserves current behaviour (you clear your own cache view if you care).InternalErrorsealed variant onUpdateStatus. Distinct fromFailed. Maps v2ReducerOutcome::InternalError(Box<str>). Carries a server-generated diagnostic string, not consumer-supplied error bytes. Lets consumers distinguish "your reducer returnedErr" from "the host encountered an internal fault executing your reducer."- Live-server v2 integration tests against a real 2.x server:
v2_protocol_negotiation_test.dart. Verifies the server acceptsv2.bsatn.spacetimedbsubprotocol and emits a cleanInitialConnectionfirst frame.v2_compression_test.dart. Round-trips compressed frames against the live server.v2_non_caller_broadcast_test.dart. Two-connection test proving connection B receives aTransactionUpdatewhen connection A writes, and B's local cache reflects the row.v2_non_caller_metadata_test.dart. Pins the v2 caller-vs-non-caller metadata contract: caller seesReducerEvent+isMyTransaction == true; non-caller observes the row insert but receivesUnknownTransactionEventand the generatedon<Reducer>listener does not fire.v2_reducer_result_variants_test.dart. Pins all fourReducerOutcomepaths:Failed(Bytes)round-trips anErr("...")message verbatim,InternalError(str)carries a server-generated diagnostic for panicking reducers, and bothOk(zero-lengthret_value) andOkEmptycollapse toretValue: null.v2_send_dropped_rows_test.dart. Pins bothunsubscribe(sendDroppedRows: true)(server delivers dropped rows + SDK fires deletes) and the defaultfalsepath (no delete events).v2_transaction_update_wire_test.dart. Captures a realTransactionUpdateframe, hand-decodes it byte-by-byte againstcrates/client-api-messages/src/websocket/v2.rs:302-355+common.rs:60-94, and cross-checks the result against the SDK'sMessageDecoder. Catches symmetric tag/order mistakes that fixture-only unit tests cannot.
check_health_test.dart. Live-server probe for the SDK's new silent-dead-socket detection.- Message-ordering stress tests (
message_ordering_stress_test.dart,_chrome_test.dart). Confirm transaction-order invariants hold under burst-y broadcast conditions on both VM and web platforms.
Changed (breaking) #
- WebSocket subprotocol:
v1.bsatn.spacetimedb→v2.bsatn.spacetimedb. SDK now only speaks v2. No version fallback. onReducerEventlistener semantics. v2 deliberately strips reducer-call metadata (caller identity, args, reducer name) from non-caller broadcasts at the protocol level. The SDK honours this by only constructingReducerEventon the caller's own path. Your generatedon<Reducer>listeners now fire for self-initiated calls only. To observe remote mutations, subscribe to the table's row streams (client.<table>.onInsert/onUpdate/onDelete); the delta is what you actually want to react to anyway.isMyTransactionderivation. Same return values for the same situations, but derived from message-type dispatch (caller path vs broadcast path) rather than byte-comparing caller connection IDs. No consumer change needed unless you were pattern-matching on the internal construction.UpdateStatussealed class reworked. New shape:Committed(DatabaseUpdate)/Failed(Uint8List errorBytes)/InternalError(String message)/Pending/Dropped.Failednow carries rawUint8List errorByteswith aString get errorMessage => utf8.decode(errorBytes, allowMalformed: true)getter, forward-compat for future typed errors without another breaking rev. Removed:OutOfEnergy(no v2 wire source; v2'sReducerOutcomeenum has no such variant).TransactionResultfields removed outright (no null-compat stubs):energyConsumed,executionDuration,isLightUpdate,isOutOfEnergy. None have v2 wire sources.- Message types deleted (no v2 equivalents):
TransactionUpdateLight,SubscribeSingle,SubscribeMulti,UnsubscribeMulti,SubscribeMultiApplied,UnsubscribeMultiApplied,InitialSubscription,IdentityTokenMessage(replaced byInitialConnectionMessage). unsubscribe()signature: now takesquerySetId: int(wasqueryId). Subscriptions are now client-assignedQuerySetId(u32) rather than server-assigned opaque IDs, which is what makes reliable resubscribe-on-reconnect possible at all.oneOffQuery()signature: now(String query, {int requestId = 0}). DroppedUint8List messageId; v2'sOneOffQuerymessage carries au32 request_idinstead.CallReducerandCallProcedurewire formats changed (v1 → v2 field order). Transparent to consumers if you go through codegen; if you were hand-constructing these messages, field order is now(tag, requestId, flags, name, args)perv2.rs:115-131and:150-166.InitialConnectionreplacesIdentityTokenas the server's first message. Field order swapped (v1:identity, token, connectionId→ v2:identity, connectionId, token). Callback renamedonIdentityToken→onInitialConnection.
Fixed #
- Silent loss of all subscriptions after a reconnect cycle. The previous
SubscriptionManager._startConnectionMonitoring()tracked reconnect intent with awasReconnectingflag that got cleared by the intermediateDisconnectedstate in the SDK's own auto-reconnect sequence (Reconnecting → Disconnected → Connecting → Connected). The finalConnectedwas then treated as a fresh connect, skipping the resubscribe. Writes kept working (reducer calls don't require a subscription), but no server broadcasts arrived. Silent, easy to miss, affected every long-running client on mobile backgrounding, wifi handoff, server restart, or any transient disconnect. Replaced the transient flag with ahasConnectedBeforemonotonic latch: any non-initialConnectedtriggers resubscribe. Validated on real iOS; previously-reproducing flake gone. ProcedureStatustag table collision. v1 Dart decoded tag1asOutOfEnergy(unit variant) and tag2asInternalError(string). v2'sProcedureStatushas two variants (tag0 = Returned(Bytes), tag1 = InternalError(Box<str>)) and would have silently misaligned the decoder on every v2InternalError(reading the string-length prefix as a phantom unit-variant dispatch). Fixed before any v2 frame touched the decoder.OneOffQueryResultResult tag order. v2'sResult<QueryRows, Box<str>>encoding uses tag0 = Ok, tag1 = Errpercrates/sats/src/ser/impls.rs:120-123. Decoder rewritten to match the canonical upstream encoding.SubscriptionErrormessage shape. Rewritten to v2 layout perv2.rs:271-291:{ request_id: Option<u32>, query_set_id: QuerySetId, error: Box<str> }. Removed v1 fieldstotal_host_execution_duration_microsandtable_id.- Stale
SubscriptionErrorrecovery regex. The v1 recovery path was looking for`(\w+)` is not a valid tablebut the actual server text (on 2.x) isno such table: \ TransactionResult.timestampwas 1000× too small for the entire v1 era. The wireTimestampisi64microseconds since Unix epoch (canonical type atcrates/sats/src/timestamp.rs, field__timestamp_micros_since_unix_epoch__). The SDK'stransaction_result.dartwas treating the wire value as nanoseconds and dividing by 1000 before feeding it toDateTime.fromMicrosecondsSinceEpoch. Result: everyresult.timestampsince v1.0.0 came back set to a date in January 1970 instead of the real transaction time. Verified empirically against a live 2.x server (a reducer call'sresult.timestampnow lands within milliseconds of the localDateTime.now()straddling the call). Bug was unnoticed because no observed consumer readsresult.timestampmeaningfully; SpaceNotes ignores it entirely. The rawReducerEvent.timestamp(Int64) field was always documented and exposed as microseconds and is unchanged; only theDateTimederivation was wrong.
Why this is a real v2 SDK #
The v2 wire protocol isn't a subprotocol flip. It changes message shapes, field orders, tag values, and the relationship between caller and broadcast frames. Half-measures (flipping the subprotocol string while keeping v1 decoders, or patching fields ad-hoc as they surface) produce a client that looks like it works until the first non-trivial transaction silently corrupts a decoder and you get garbled data three message types downstream from the real misalignment.
What this release does instead:
- Every v1-only message type is deleted, not stubbed. No
TransactionUpdateLight, noSubscribeMulti*, noInitialSubscription. If the compiler lets a v1 reference survive, the code path is dead. There's no v2 frame that would ever route to it. - Every v2 tag value is verified against upstream source, not inferred from docs or reused from v1 by position.
ClientMessagetags areSubscribe(0), Unsubscribe(1), OneOffQuery(2), CallReducer(3), CallProcedure(4)perv2.rs:18-29. Every tag differs from v1.ServerMessagetags (v2.rs:175-196) likewise. - Every field-order change in
CallReducer/CallProcedure/Subscribe/Unsubscribe/OneOffQueryis reflected in the encoder. The difference between "encoder writes v1 order under v2 subprotocol" and "encoder writes v2 order" is the difference between a silently-broken reducer call and a working one. There is no runtime error, just garbage args. - Caller-path vs broadcast-path semantics are modelled explicitly. v2's deliberate choice to strip reducer metadata from non-caller broadcasts requires the SDK to route
ReducerResult(caller) andTransactionUpdate(broadcast) through different handlers and buildReducerEventon one path, not both. Generatedon<Reducer>listener contracts reflect this. - Resubscribe after reconnect actually works. This was silently broken under v1 too (see Fixed). A v2 SDK that inherits the v1 resubscribe bug is a v2 SDK that drops all your queries on the first wifi handoff.
- Live-server integration tests, not unit mocks.
v2_protocol_negotiation_test,v2_compression_test,v2_non_caller_broadcast_test,v2_non_caller_metadata_test,v2_reducer_result_variants_test,v2_send_dropped_rows_test,v2_transaction_update_wire_test,check_health_test,message_ordering_stress_test*all open real WebSocket connections to a live 2.x server and assert against actual server frames. Unit tests give you "I wrote the encoder I intended to write"; integration tests give you "the server on the other end agrees."v2_transaction_update_wire_testgoes one further: it bypasses the SDK's own decoder and parses raw bytes against the upstream Rust schema, then cross-checks against the SDK's decoder. A symmetric tag-or-order mistake (e.g. swappinginsertsanddeletes) would self-consistently round-trip on fixtures the same code produced; this test does not.
Behaviours worth knowing about #
These aren't SDK choices; they're protocol and server behaviours surfaced by the pre-release validation pass. Documented here so consumers don't run into them blind:
- Reducer panic messages are scrubbed by the server. A reducer that panics with
panic!("intentional reducer panic")reaches the client asInternalError(message: "the instance encountered a fatal error."). The original panic text does not cross the wire (upstream privacy behaviour, not an SDK transformation). If you want consumers to see a specific message, returnErr("...")from the reducer; that path (Failed(Bytes)) round-trips the user-supplied string verbatim. (Verified against SpacetimeDB 2026-04-25.) - Generated
on<Reducer>listeners no longer fire on remote writes. This is the v2 design intent (clients are entitled to table state, not how the state got there) and it lands as a behavioural change for any consumer that was using the v1 listener API. To react to remote mutations, subscribe to the table's row streams (onInsert/onUpdate/onDelete) and read the row data; that's the audit-trail-free signal v2 commits to delivering. If you genuinely need to know which connection performed a write, model it as explicit data: a row in aneventsoraudit_logtable the reducer writes. RowSizeHintvariants are not stable perTableUpdate. A singleTableUpdatecan carry inserts encoded asFixedSize(N)and deletes encoded asRowOffsets([]). Observed inv2_transaction_update_wire_test. The SDK's decoder handles both variants on eachBsatnRowList; consumers that hand-decode wire frames must do the same.
1.1.1 - 2026-04-18 #
Packaging / docs patch. No runtime changes.
Changed #
- Bumped
brotliconstraint from^0.5.0to^0.6.0to pick up the latest stable. No API change; the SDK only callsbrotli.decode(bytes), which is unchanged. - Pubspec description now mentions
SubscribeMulti. New README "Compatibility" section explicitly calls out SpacetimeDB 2.x server support and theSubscribeMultisubscription protocol.
Fixed #
- Two dartdoc
INFOlints whereMap<String, dynamic>andVec<u8>were written bare in comments (interpreted as HTML). Wrapped in backticks.
1.1.0 - 2026-04-17 #
Added #
Option<T>codegen support. Rust tables, reducer args, and views that useOption<T>now generate clean nullable Dart fields (T?) withwriteOption/readOptionround-trips. Previously, any module usingOption<T>failed codegen with "inline sum types are not supported." Matches the first-class Option handling in the Rust and TypeScript SDKs.- New
OptionTypeIR variant.AlgebraicType.fromJsondetects the canonical[some(T), none]sum shape (order-strict, lowercase names,nonecarries a unit payload) per upstreamSumType::as_option. - Option-returning views detected as
ViewReturnType.optionvia the new IR variant. copyWithcorrectly emitsT?(no moreString??double-nullable) for Option fields.
- New
Fixed #
writeOption/readOptionBSATN wire format. The previous implementation used abooldiscriminant with reversed semantics (Some=1, None=0), but BSATN's canonical Option encoding uses sum-variant indices (Some=0, None=1) percrates/sats/src/ser/impls.rs. As a result, any server-sentOption<T>field decoded viareadOptionsilently returnednullfor Some values. Now writes and reads u8 tags that match the wire spec; throwsSpacetimeDbProtocolExceptionon invalid tag.SubscriptionErrorMessage.decodewire alignment. Was structured around the brokenreadOptionsemantic and misread theSubscriptionErrormessage: skippedtable_identirely and treatederroras optional. Reordered to match the canonical layout (u64 duration; Option<u32> request_id; Option<u32> query_id; Option<u32> table_id; String error).OutOfEnergydecode reads phantom payload.TransactionUpdateMessage.decodewas reading a phantomreadString()after the OutOfEnergy status tag, misaligning subsequent fields. Per the wire definition (crates/client-api-messages/src/websocket/v1.rs), OutOfEnergy is a unit variant with no payload. Fixed.
Changed (breaking, but phantom data only) #
OutOfEnergy(inupdate_status.dart) no longer has abudgetInfo: Stringfield or a string constructor. The field only ever carried garbled bytes from the misaligned decoder; it was never a real value. Consumers constructingOutOfEnergy('...')must now useOutOfEnergy().TransactionResult.errorMessagereturns'Out of energy'instead of'Out of energy: <info>'. This is technically a breaking change but nobody could have written working code against the old field, so it's ordinarily listed under a minor bump rather than a major.
1.0.1 - 2026-04-15 #
Docs only. README's Quick Start install snippet and codegen command lines still referenced the pre-rename package name (spacetimedb_dart_sdk). Fixed to the published name (spacetimedb_sdk).
1.0.0 - 2026-04-15 #
First public release on pub.dev.
This version consolidates a multi-month pre-release development effort into the first stable, published API. Everything below was already in place before publication; the CHANGELOG entries exist as a reference for anyone who was tracking the SDK via git before 1.0.
Reactive primitives #
TableCache<T>.rows:ValueNotifier<List<T>>that fires on every transaction touching the table.TableCache<T>.lastBatch:ValueNotifier<TransactionBatch<T>?>carrying every row change from the most recent transaction. Fires exactly once per transaction with aList<TableEvent<T>>(insert / update / delete subtypes).TableCache<T>.rowNotifier(primaryKey): per-row auto-disposingValueNotifier<T?>that fires only when that specific row's value changes. Scales to thousands of concurrent row-watchers atO(rows_touched)cost per transaction, notO(listeners × events).TableCache<T>.onInsert/onUpdate/onDelete: broadcastStream<TableEvent<T>>for consumers that react to one kind of change (no iteration or type-ladder needed). Fire synchronously in the same transaction aslastBatch.TableCache<T>.subscribed:Future<void>that resolves when the server delivers the initial batch for this table (including empty tables).
Typed client #
- Code generation from Rust module: tables, reducers, sum types (Rust enums → Dart sealed classes with exhaustive matching), views (
Vec<T>,Option<T>, single-row). - Reducers become typed async Dart methods returning
TransactionResult(energy cost, server timestamp, queued/dropped status). - Views exposed as direct accessors (
client.activeUsers,client.currentAdmin).
Optimistic updates #
OptimisticChange.insertRow(tableCache, row)/updateRow/deleteRow: typed helpers that extract the table name and serialize via the decoder.nextOptimisticIntId(): utility for client-side temporary primary keys.- Pass
optimisticChanges: [...]on any reducer call; the SDK applies the writes locally, keeps them on server-ack, or rolls them back on rejection. - Multi-table reducer support: stage one
OptimisticChangeper table write.
Offline storage #
OfflineStorageabstract class:saveTableSnapshot/loadTableSnapshot, mutation queue, per-table last-sync timestamps.JsonFileStorage: durable file-based implementation.InMemoryOfflineStorage: for tests.- Cached reads work without a connection; writes queue while disconnected and replay in order on reconnect.
client.onSyncStateChanged+client.onMutationSyncResultstreams for sync-state UI.
Exception handling #
Sealed SpacetimeDbException root with seven typed subtypes (Reducer, Connection, Auth, Timeout, Schema, Protocol, Subscription). on SpacetimeDbException catch (e) covers every SDK runtime failure.
connect()wraps rawSocketException/HandshakeException/WebSocketExceptionintoSpacetimeDbConnectionException.- BSATN decode errors throw
SpacetimeDbProtocolException. SpacetimeDbAuthExceptionextendsSpacetimeDbConnectionException.
Connection #
- Sealed
ConnectionState(Connecting/Connected/Reconnecting/Disconnected/AuthError/FatalError);switchexhaustively. client.connection.onStateChanged:Stream<ConnectionState>.- Automatic reconnection with exponential backoff.
Extensions #
ValueListenable<T> extensions for common async patterns:
firstNonNull(): resolve with first non-null value (current or future).firstWhere(predicate): resolve when predicate first holds.next: resolve on next change, ignore current.toStream(): bridge toStream<T>forStreamBuilder/ rxdart interop.
Migration from pre-1.0 consumers #
The pre-1.0 package name was spacetimedb_dart_sdk. On publication to pub.dev the package is now spacetimedb_sdk. Consumers pinned to a git SHA must update their pubspec.yaml:
dependencies:
spacetimedb_sdk: ^1.0.0 # was: spacetimedb_dart_sdk (git)
And every import:
// before
import 'package:spacetimedb_dart_sdk/codegen.dart';
// after
import 'package:spacetimedb_sdk/codegen.dart';
Any code generated against the old package also needs regenerating via dart run spacetimedb_sdk:generate.