spacetimedb_sdk 2.3.1
spacetimedb_sdk: ^2.3.1 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.3.1 - 2026-07-20 #
Code-generation fixes, all surfaced by consumers integrating generated clients. No runtime-API changes. Generated clients from 2.3.0 keep working; regenerating against 2.3.1 is additive.
Added #
- Standalone
#[SpacetimeType]product structs. A named product type used as a reducer parameter or field but not backed by a table (e.g. aServerPositionreducer argument) produced no Dart class, forcing consumers to hand-write it. It now emits a full data class alongside table and sum-type classes. ScheduleAt/TimeDurationspecial types. Generated clients now handleScheduleAtcolumns on#[scheduled]tables andTimeDurationfields, and synthesize named companion classes for inline (anonymous) sum types instead of failing on an unhandledIrSumType.
Fixed #
- Single-field
hashCodecrash. A table or struct with exactly one field generatedObject.hash(x), which throws at runtime (it needs at least two arguments). Now usesObject.hashAll([...]), correct for one field or many. - Wrong BSATN method on struct/table references.
reducers.dartemitted.encode()/.decode()on product and table references, but the generated classes exposeencodeBsatn/decodeBsatn, so the output did not compile. Encode and decode are now unified on the*Bsatnmethods across all generated tables, structs, sum types, synthesized inline-sum companions, and the runtimeScheduleAttype. The hand-written wire-protocol.encode()/.decode()API is a separate surface and is unchanged. - Zero-field struct broke
operator ==. An empty-field struct generatedother is X && ;, a syntax error. Equality now guards the zero-field case. - Class name and cross-reference name could drift for non-PascalCase type names. Type-class naming and reference resolution now share one transform, so a
#[allow(non_camel_case_types)]type name cannot emit a class the rest of the generated code fails to reference.
2.3.0 - 2026-07-12 #
Offline-first correctness hardening. This release closes several silent data-loss and data-integrity gaps on the offline mutation and reconnect paths, adds a subscriptionsReady readiness signal so consumers can tell a genuinely-usable connection from a merely socket-open one, and makes the previously-dead queuePolicy reachable from generated clients. All additive — existing consumers keep working unchanged.
Added #
subscriptionsReady(ValueListenable<bool>) onSubscriptionManager/ generated clients. True only when the connection is open AND every active query set has had itsSubscribeAppliedapplied to the cache; latches back to false the moment the socket drops or a reconnect begins resubscribing, and returns to true only after all query sets re-apply.Connectedis socket-open, not usable — gate sends/reads onsubscriptionsReadyto avoid a false-positive connected state.queuePolicyreachable from generatedcreate(). TheOfflineQueuePolicyknob (TTL, queue bounds,onBeforeReplayveto) is now threaded through the generatedcreate()intoSubscriptionManager— previously it was unreachable and inert for consumers.SpacetimeDbStorageException— post-send dequeue/storage failures are now classified as storage errors instead of being mislabeled as network errors.TableCache.applyServerDelta/applySnapshot/dropQuerySet— new internal,querySetId-labeled apply methods used exclusively bySubscriptionManagerfor every server-sourced write to a primary-key table. Ownership of a row (which query sets currently have it in their result set) is now tracked per-row and derived only from these labeled wire deltas, closing a class of bugs where a row delivered outside the initialSubscribeAppliedsnapshot (a liveTransactionUpdate, or a caller's own committed reducer result) could be silently evicted by an unrelated query set's later apply. Rows written through the pre-existing unlabeled methods (applyTransactionUpdate,applyInitialData,applyTransactionUpdateAndCollectKeys— unchanged, still released, still used for no-primary-key and event tables) get no ownership entry; they behave like any other offline-hydrated stray, pinned while optimistic, otherwise healed or evicted on the next coveringSubscribeApplied.
Fixed #
- Timed-out UPDATE/DELETE no longer false-confirmed on a flaky link. A queued UPDATE/DELETE whose send timed out while the reducer never ran on the server was previously confirmed against the client's own optimistic overlay in the cache and silently dropped from the durable queue — a lost write. Confirmation now uses server provenance; an unverifiable timeout keeps the mutation queued for at-least-once retry.
- Stacked cross-request UPDATE no longer deletes a committed row on rollback. When several offline edits stacked on the same primary key and one failed, older-first rollback could delete a row that actually sat on a committed server value. Rollback now restores the true committed base at rollback time and re-applies surviving overlays on top.
- No-primary-key rows no longer duplicate on restart or phantom on rollback. No-PK optimistic rows are tracked by request id rather than content-equality, fixing nested-column snapshot duplication, rollback phantoms, and identical-row ambiguity; optimistic overlays are excluded from persisted snapshots.
- Reducer-timeout reconciliation. On timeout the SDK reconciles against the cache before retrying (confirm if landed, keep queued if unverifiable) to prevent duplicate server execution; an unverifiable timeout is only discarded while still connected, and kept when the connection dropped so a flaky link can't lose writes. The retry backoff is clamped so it can't overflow to a zero-delay hot loop.
- Cross-client overlay protection. A cross-client committed row is now stashed when protection is skipped, so a later optimistic rollback restores server truth instead of resurrecting a stale pre-optimistic snapshot; another client's commit can't clobber a pending optimistic overlay.
- Reconnect resubscribes in place without dropping the query set; reconnect eviction is skipped when resubscribe is interrupted so a mid-reconnect disconnect can't wipe the cache.
subscribe()never hangs. It completes on subscription error, disconnect, and dispose; the subscribe waiter always completes even ifSubscribeAppliedhandling throws.dispose()is idempotent and ignores connection-state events (and the reconnect resubscribe tail) after dispose, so teardown never writes the disposedsubscriptionsReadynotifier.- Journal-append-before-mutate ordering so append failures can't diverge queue state or resurrect confirmed mutations; optimistic changes roll back when an enqueue write fails so a failed queue write can't leave phantom rows.
Changed (observable, opt-out not available) #
- A row visible to two or more overlapping query sets on the same table now fires exactly one insert event and exactly one delete event, on the 0→1 and →0 ownership transitions respectively — matching the official SpacetimeDB SDKs' semantics. Previously such a row could double-fire insert/delete events once per owning query set. This is very likely a latent-bug fix for any consumer relying on
onInsert/onDelete/lastBatchcounts on a table subscribed via more than one overlapping query, but it is an observable behavior change and is called out here per house convention for released-behavior deltas.
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.