For the complete documentation index, see llms.txt. This page is also available as Markdown.

Implementing a persistent store

Every DeRec SDK requires the application to supply five storage adapters and one outbound transport. This chapter is the schema-and-query level walkthrough of the five store adapters at the data-model layer — partition keys, conceptual storage shape, queries the library makes, and idempotency contracts. It is language-agnostic on purpose: the per-language wiring lives in the SDK chapters, and the shape documented here is the same across all of them.

Read first: the Rust, .Net, TypeScript, React Native, and Go SDK chapters for the language-specific wiring, and the Concepts chapters for protocol context. Two reference implementations ship in the lib-derec repository — SQLite under smoke-tests/sqlite/ and Postgres under smoke-tests/postgres/. Both implement all five stores durably over the same schema shape, both are intentionally small (a few hundred lines each), and either can be copied wholesale as a starting point for a new backend.


The five stores

The library splits persistence along five orthogonal axes: paired channels, the shares each channel holds, per-channel cryptographic secrets, the application-supplied Secret snapshot, and the orchestrator's in-flight protocol state. Each store is partition-keyed by secret_id so a single backing database can serve many Secrets on the same device without leakage — the library never queries across secret_ids.

The trait method names below are quoted verbatim from library/src/protocol/traits.rs; signatures are simplified to focus on the parameters and the return value. Each Rust method actually returns a typed future — SecretStoreFuture, ChannelStoreFuture, ShareStoreFuture, or StateStoreFuture — wrapping the value shown. The bindings differ in shape, not in contract: see Binding shapes.

DeRecChannelStore

  • Partition key — secret_id (u64).

  • Conceptual shape — a ChannelRecord: an enum over two shapes that are stored and keyed differently.

Variant
Inner type
Key

ChannelRecord::Helper

HelperChannel — one Owner↔Helper channel

(secret_id, channel_id)

ChannelRecord::Replica

ReplicaMember — one member of the replica group

(secret_id, replica_id)

Both inner records carry transports: every endpoint the peer advertised, in the order it offered them. The library never ranks the list; it hands it to DeRecTransport::send. A recorded channel always has at least one endpoint.

Persist the serialized record verbatim. Where a store handles channel records as bytes — the TypeScript and React Native ChannelStore, or a Rust store that serializes with the serde feature, as the SQLite reference does — the record is a blob whose shape the library owns, leading with "schema_version": 3 (CHANNEL_RECORD_SCHEMA_VERSION). The library migrates old shapes itself on load:

  • A record without schema_version reads as version 0 and is upgraded.

  • A legacy single transport field is lifted into a one-element transports list.

  • A record naming no endpoint at all — no transport, and transports absent or empty — is a load error, not an unreachable channel.

  • A record with a schema_version newer than the build understands is refused rather than read and written back truncated.

The .NET and Go IChannelStore / ChannelStore receive typed ChannelRecord values; the bridge does the encoding.

The store also owns the channel-link graph: an undirected, idempotent, transitive relation recording that two channels belong to the same Owner identity. It exists for one situation — an Owner that lost its state re-pairs with a Helper on a fresh channel, and the Helper must still find the shares it stored under the old one. The library never writes links; the helper-side application recognises a returning Owner (an out-of-band judgement the protocol cannot make) and records the link. The library only reads the graph, in two helper-side responders: answering GetSecretIdsVersions and answering GetShare.

Replica groups do not use it. Members share one channel_id and are identified by replica_id, so there is nothing to link; a catch-up resolves peers through the roster instead.

Required operations:

  • load(secret_id, query: ChannelQuery) -> Option<ChannelRecord> — ChannelQuery is Helper { channel_id } or Replica { channel_id, replica_id }

  • save(secret_id, record: ChannelRecord) -> () — the key is derived from the record, which is what stops a record being stored under the wrong one

  • remove(secret_id, query: ChannelQuery) -> bool — true iff an entry was removed. Removing one ChannelQuery::Replica removes that member only; the group channel and every other member survive

  • helpers(secret_id, filter: HelperFilter) -> Vec<HelperChannel> — the helper channels in the partition that filter selects; see Channel filters

  • replicas(secret_id, filter: ReplicaFilter) -> Vec<ReplicaMember> — the group members that filter selects, including this device's own row unless the filter excludes it

  • link_channel(secret_id, a, b) -> () — record an undirected, idempotent, transitive edge

  • linked_channels(secret_id, channel_id) -> Vec<ChannelId> — the connected component, including channel_id itself

replicas ordering is your succession policy

The returned order is insignificant with exactly one exception, and that exception is a real design decision disguised as a query detail.

A replica group has exactly one member holding ReplicaRole::Source. When that member is removed, a successor must be chosen, and the protocol takes the first element returned for a filter that already excludes the departing member and admits only members not themselves leaving. Implementing replicas is therefore how an application chooses its own succession policy — order by an added_at column, by a user-chosen preference, by whatever a backend knows — without the protocol having to model one.

The choice is read once, on the single device that runs the removal, and is then published in the roster as the member's role. Every other member reads the result rather than repeating the decision, so implementations on different devices need not agree on order.

A source that is the group's only member leaves no successor, and the group dissolves with it. See Replica removal.

Concrete SQLite schema (from smoke-tests/sqlite/migrations/0001_init.sql):

channel_links is the undirected adjacency list backing link_channel / linked_channels. The status, peer_role and role columns exist only so the listings can push the channel filter into a WHERE clause. Idempotency: re-saving a record replaces the row at its key; re-linking an already-linked pair is a no-op.

Binding shapes differ. Rust takes a typed ChannelQuery; the other bindings flatten it into a (secretId, channelId, replicaId) triple where replicaId == 0 — the value the protocol reserves as "absent" — addresses the helper channel. save takes the record: save(secret_id, record) in Rust, Save(secretId, ChannelRecord) in .NET and Go, save(secretId, channelId, replicaId, bytes) in TypeScript and React Native.

Binding
Helper listing
Replica listing

Rust

helpers(secret_id, HelperFilter)

replicas(secret_id, ReplicaFilter)

.NET

ListHelpers(ulong secretId, HelperFilter filter)

ListReplicas(ulong secretId, ReplicaFilter filter)

Go

ListHelpers(secretID uint64, filter HelperFilter) ([]HelperChannel, error)

ListReplicas(secretID uint64, filter ReplicaFilter) ([]ReplicaMember, error)

TypeScript / React Native

listHelpers(secretId, filter)

listReplicas(secretId, filter)

In TypeScript and React Native, ids are decimal strings, and listHelpers / listReplicas return a JSON array of the inner records with the union tag stripped — not an array of ChannelRecord. Build it by splicing the stored bytes as text rather than JSON.parse-ing and re-serialising: every id in these records is a u64, and JSON.parse silently rounds anything above 2^53.

Channel filters

helpers and replicas take a filter, crate::protocol::types::ChannelFilter { ids, status, role, exclude }, instantiated twice:

Alias

Listing

ids are

role is

HelperFilter

helpers

HelperChannel::channel_id

the peer's HelperChannel::peer_role (SenderKind)

ReplicaFilter

replicas

ReplicaMember::replica_id

ReplicaMember::role (ReplicaRole)

  • Every field is a restriction, and every field's empty value means "do not restrict on this": an empty ids, status or exclude list, and a role of None / null.

  • Restrictions combine with AND.

  • exclude is applied last and overrides ids: an id in both is omitted.

The unrestricted filter is Default::default() in Rust, HelperFilter.Any / ReplicaFilter.Any in .NET, the zero struct in Go (Role is a pointer, so nil means any role), and { ids: [], status: [], role: null, exclude: [] } in TypeScript and React Native.

Returning every record under secret_id and ignoring the filter is correct, and the implementation to write unless there is a measured reason not to. The library re-applies the filter to whatever a listing returns and drops what it excludes before anything acts on it.

Pushing the filter into the query — a WHERE clause, a key-condition expression — is an optimization. It saves transferring rows the caller discards, which matters on a metered backing that bills by bytes read. It also re-expresses the filter's semantics by hand, and the two ways of getting that wrong are not symmetric.

A store that pushes the filter down should be tested against library/tests/fixtures/channel_filter.json, a table of (records, filter, expected) cases covering empty-means-unrestricted per field, role as an optional, exclude applied after ids, and ids above 2^53. Every binding's own test suite drives it.

A store that cannot express the filter as a query can list and apply the same predicate the library re-checks with, in memory:

Binding
Predicate

Rust

ChannelFilter::matches(&id, status, &role)

.NET

HelperFilter.Matches(ulong id, ChannelStatus status, SenderKind role) / ReplicaFilter.Matches(...)

Go

HelperFilter.Matches(channelID, status, role) / ReplicaFilter.Matches(replicaID, status, role)

TypeScript / React Native

channelFilterMatches(filter, id, status, role)


DeRecShareStore

  • Partition key — secret_id.

  • Conceptual storage key — (secret_id, channel_id, version). Exactly one share exists per key: a version has exactly one writer.

Each entry is opaque protobuf bytes — the byte format depends on which side stored it (Owner-side stores a CommittedDeRecShare; Helper-side stores the full StoreShareRequestMessage). The store itself never decodes.

There is no replica_id in this key. The protocol enforces single-writer-per-version before calling the store: a second write at an existing version carrying different content is refused with VERSION_CONFLICT and never reaches the store, while a byte-identical re-send is an idempotent retry. Implementations therefore overwrite on the three-tuple key and need no notion of who wrote a version.

Helpers in particular hold no replica identity at all — replicaId is omitted from helper-bound StoreShareRequestMessages entirely, so there is no writer to disambiguate. Earlier revisions of this store carried a fourth replica_id component; that contract is gone.

Required operations:

  • load(secret_id, channel_id, versions: &[u32]) -> Vec<Share> — empty versions slice means "all versions"; missing versions are silently skipped

  • load_many(secret_id, channel_ids: &[ChannelId], versions: &[u32]) -> Vec<Share> — same shape across multiple channels in one round-trip; used by broadcast queries and by recovery, over the set returned by linked_channels

  • load_all(secret_id, channel_ids: &[ChannelId]) -> Vec<Share> — every share for every listed channel and every version; used by Discovery

  • latest_version(secret_id) -> Option<u32> — highest version across all channels in the partition, or None if empty

  • save(secret_id, channel_id, share) -> ()

  • remove_channel(secret_id, channel_id) -> () — drop every share under the channel; idempotent

  • remove_versions(secret_id, channel_id, versions: &[u32]) -> () — drop the shares under (secret_id, channel_id) at each listed version. Idempotent: a version that is not stored is skipped, and an empty slice is a no-op. Never touch other channels or other partitions. A Helper calls it to apply the Owner's keepList

  • keep_list(secret_id, version) -> Option<Vec<u32>> — Owner only: the versions every Helper keeps after version is distributed. See Owner keep list

Concrete SQLite schema (from smoke-tests/sqlite/migrations/0001_init.sql):

secret_id is the partition argument and leads the key. The share_secret_id column carries Share::secret_id as data; it is not part of the key and is not expected to match secret_id.

The Postgres binding (smoke-tests/postgres/migrations/0001_init.sql) expresses the identical key as CONSTRAINT shares_uniq UNIQUE (secret_id, channel_id, version) with BIGINT / BYTEA column types.

For how concurrent writers are resolved at the protocol level, see Replica conflict resolution and VERSION_CONFLICT.

Owner keep list

Every StoreShareRequestMessage carries a keepList: the complete set of versions the Helper keeps. A Helper deletes every stored version not on it, through remove_versions, once the incoming share is stored. An empty keepList deletes nothing. The Helper never deletes the version the request itself carries, and ignores the keepList of a request older than the latest version it holds on that channel, so a replayed or delayed request cannot delete newer shares. See Version retention.

The Owner builds the list by calling keep_list(secret_id, version) on its share store, where version is the version about to be distributed:

  • It is called once per sharing round, before anything is sent, on every round that distributes shares to Helpers: an application-started ProtectSecret and the library-started publishes (pair completion and fingerprint confirmation) alike. Every Helper in the round receives the same list.

  • Some(list): Helpers keep exactly list plus version. The library always adds version, removes duplicates and sorts ascending, so list may omit it. Some([]) keeps only the version being distributed.

  • None: an empty keepList is sent, and Helpers keep every version they hold. An application that wants to cap how many versions Helpers retain returns that cap as a list.

  • Replica pushes never consult keep_list and always send an empty keepList.

Only the application knows which versions committed: it learns that from SharingComplete { threshold_met } and ShareConfirmed, and nothing in the stores records it. The rule for a list is to keep every version that could still become the latest:

  • List every version that committed, such as those whose SharingComplete reported threshold_met. The version being sent has not committed when keep_list is called, and its round may never reach threshold.

  • List every version whose round is still open. If the Owner sends v5 while v4's round is open and leaves v4 out, Helpers delete v4's share. If v5 then fails and v4 commits, the version the Owner considers latest is gone from those Helpers.

  • Leave out only versions whose round failed, or that the user rolled back.

A replica member that became the source cannot tell which of the versions it mirrored committed on the previous source. Listing every mirrored version it holds is safe: at worst Helpers keep a version that was rolled back.

Binding

remove_versions

keep_list

Rust

remove_versions(secret_id, channel_id, &[u32])

keep_list(secret_id, version) -> Option<Vec<u32>>

.NET

void RemoveVersions(ulong secretId, ulong channelId, uint[] versions)

uint[]? KeepList(ulong secretId, uint version) — null for none

Go

RemoveVersions(secretID, channelID uint64, versions []uint32) error

KeepList(secretID uint64, version uint32) (versions []uint32, ok bool, err error) — ok=false for none

TypeScript / React Native

removeVersions(secretId, channelId, versions: number[]): Promise<void>

keepList(secretId, version): Promise<number[] | null | undefined> — null / undefined for none

The SQLite reference returns None from keep_list, so its Helpers keep every version.


DeRecSecretStore

  • Partition key — secret_id.

  • Sub-key — (channel_id, kind) where kind is SecretKind::{SharedKey, PairingSecret, PairingContact}.

  • Conceptual shape — per-channel cryptographic material. SharedKey is the post-pairing symmetric channel key (sensitive — implementations should persist with keychain-grade protection). PairingSecret is the ephemeral ECIES / ML-KEM key material used during pairing, removed once the shared key is derived; the ML-KEM-1024 decapsulation key in it is stored as its 64-byte seed. PairingContact is the ContactMessage that create_contact stores in NoKeys mode, where no key material exists yet; it is removed once pairing completes.

The library decides which kind to store; the application implements blind CRUD.

Required operations:

  • load(secret_id, channel_id, kind) -> Option<SecretValue>

  • load_many(secret_id, channel_ids: &[ChannelId], kind, missing_policy: MissingPolicy) -> Vec<(ChannelId, SecretValue)> — used by broadcast paths; missing_policy is either Skip (silently drop missing channels) or Fail (return MissingEntries carrying the unmatched channel ids). The Rust trait only: the .NET ISecretStore.LoadMany, Go SecretStore.LoadMany and TypeScript / React Native loadMany(secretId, channelIds, kind) take no policy. They must return exactly one entry per requested channel, in request order, with null (nil in Go) where nothing is stored. The library decides whether a missing entry is an error, and refuses an answer of the wrong length

  • save(secret_id, channel_id, value: SecretValue) -> () — kind is inferred from the SecretValue variant

  • remove(secret_id, channel_id, kind) -> () — idempotent

Concrete SQLite schema:

VSS guarantee: individual Verifiable Secret Sharing shares reveal zero information about the original secret, so share storage does not require keychain-grade protection — only this store does.


DeRecUserSecretStore

  • Partition key — secret_id.

  • Conceptual shape — at most one entry per secret_id — the latest application-supplied Secret snapshot. The library reads this so the pair-completion auto-publish hook can ship the current Secret to a freshly-paired Helper or Replica Destination without an explicit re-publish from the app.

Required operations:

  • load_latest(secret_id) -> Option<UserSecrets>

  • save_latest(secret_id, value: UserSecrets) -> () — overwrites any prior entry; the store keeps only the latest snapshot

  • remove(secret_id) -> () — idempotent

Concrete SQLite schema:

The version column mirrors UserSecrets::version — the monotonically increasing Secret version the protocol bumps on every publish. description is the optional human-readable label forwarded to helpers in StoreShareRequest.description. payload is the encoded UserSecrets::secrets blob.

author_replica_id mirrors UserSecrets::author_replica_id: the replica member that published the stored version, or absent when no group member did (an Owner with no replica identity, or a snapshot rebuilt by recovery). It is Option<u64> in Rust, ulong? in .NET, *uint64 in Go and an optional decimal string in TypeScript and React Native.


DeRecStateStore

  • Partition key — secret_id.

  • Sub-key — StateKey, a typed key discriminated by StateKind. There are five kinds:

Wire kind

StateKey variant

Secondary key

0

PendingVerification { channel_id }

channel_id

1

PendingRecovery { secret_id, version }

secret_id and version

2

PendingUnpair { channel_id }

channel_id

3

SharingRound { version }

version

4

PendingReplicaDiscovery

none — at most one row per secret_id

  • Conceptual shape — the orchestrator's in-flight protocol state: outstanding verification challenges, in-progress recovery accumulators, pending unpair acknowledgements, open sharing rounds, and the active replica catch-up. This is the state that makes DeRecProtocol effectively stateless — nothing is held in process memory between calls. In a long-lived process an in-memory map suffices; in a stateless or load-balanced deployment, back this with the shared database so in-flight flows survive process restarts and instance churn. See Failure Modes.

Two details of the key are load-bearing rather than cosmetic:

SharingRound is keyed by version — it is not a single slot. Several rounds can be open at once, because publishes are started by the pair-completion hook and by the promotion inside verify_fingerprint, not only by start(ProtectSecret), and both of those run while handling an inbound message. A single unkeyed row meant a round started that way silently replaced one already in flight, after which neither completed — responses for the replaced round arrived against state that no longer existed, and no SharingComplete was emitted for either. An application could not prevent this, because it cannot see the library's round state.

PendingRecovery is keyed by (secret_id, version), not version alone. The secret_id here is the secret being recovered, which is deliberately not the one naming the partition: a recovering device runs an ephemeral instance whose own id owns the partition, while the target belongs to the wire. Keying on it lets one instance recover several secrets concurrently at the same version without their accumulators colliding.

A store may derive the row key from the item. In the serialized form the TypeScript and React Native stores receive (and that the SQLite and Postgres references persist), an item's key fields — channel_id, secret_id and version — are present exactly when its key has them, with the same values. PendingReplicaDiscovery has no key field: the device's version at the start of the catch-up travels in local_version. Rows written before 0.0.6 that carry it in version still decode.

A PendingRecovery saved before 0.0.7 has no record of which channel each collected share arrived on; it decodes as having collected nothing, and the recovery re-collects its shares. The payload is opaque, so the schema is unaffected.

Two of the five kinds are accumulators — PendingRecovery (collecting shares toward the threshold) and SharingRound (tracking which Helpers have confirmed and which members have acknowledged). The library grows them via load-modify-save: it loads the current item, mutates it, and saves it back. save is a full-replacement upsert — there is no per-item merge or append at the store layer.

Required operations:

  • save(secret_id, item: StateItem) -> () — full-replacement upsert keyed by (secret_id, item.key())

  • load(secret_id, key: StateKey) -> Option<StateItem> — pure read; None when absent

  • remove(secret_id, key: StateKey) -> bool — idempotent; true iff a row was removed

  • load_all(secret_id, kind: StateKind) -> Vec<StateItem> — every item of the given kind under this secret_id, in no guaranteed order; used for timeout sweeps

A durable backend is a table keyed by (secret_id, kind, sub_a, sub_b) with an opaque StateItem payload blob. Two integer columns are needed because the secondary key differs per kind, and PendingRecovery needs both. This is the SQLite reference (smoke-tests/sqlite/migrations/0001_init.sql); the Postgres binding is identical modulo column types:

save upserts on the full primary key; load_all filters by (secret_id, kind).

Because the load-then-remove path during response handling is two round-trips, a load-balanced deployment may process a duplicate response — every library-emitted event is idempotent, so this is safe. Accumulator concurrency is a different matter and is the application's responsibility: see Concurrency and scheduling.


Common patterns and pitfalls

  • All five stores are partition-keyed by secret_id. Multiple Secrets coexist in one database without leakage. The library never issues a cross-partition query. This is also what makes different secret_ids safe to drive concurrently with no coordination — see Concurrency and scheduling.

  • Store methods are future-shaped in Rust and Promise-shaped in TypeScript and React Native; .NET and Go are synchronous. See Binding shapes.

  • The library holds each store by &mut Self. Concurrent calls into the same store instance do not happen — implementations need no internal synchronisation. This is a statement about one protocol instance, not about the deployment: two instances against the same secret_id must be serialised by the application.

  • secret_id partitioning is enforced by the queries, not by the data type. Every public method takes secret_id as its first parameter; a backend that omits it from its WHERE clauses will leak across Secrets.

  • Share::secret_id is data, not the partition. On a Helper it is the Owner's id and routinely differs from the secret_id argument. Key on the argument; never assert the two are equal. See DeRecShareStore.

  • ChannelStatus has three variants, and Unpairing is replica-member only. It marks a member that has been told to leave and is awaiting the roster version that completes its removal, and is never set on a helper channel. A store that validates the enum on load MUST accept it — rejecting a value the core emits has been a shipped bug in more than one binding.

For how the protocol resolves two members writing the same version, see Replica conflict resolution.

Binding shapes

The contract is the same in every SDK; the method shape is not.

Binding
Store interfaces
Absent value
Ids

Rust

Traits returning a typed future (ChannelStoreFuture<'_, T>, …). A sync backend returns Box::pin(std::future::ready(...))

Ok(None)

u64 / ChannelId

.NET

IChannelStore, IShareStore, ISecretStore, IUserSecretStore, IStateStore — synchronous methods

null (ChannelRecord?, uint?, …)

ulong

Go

ChannelStore, ShareStore, SecretStore, UserSecretStore, StateStore — synchronous methods

single-value loads return (value, ok, err); listings return (slice, err); Remove returns (existed, err)

uint64

TypeScript

ChannelStore, ShareStore, SecretStore, UserSecretStore, StateStore — every method returns a Promise

null or undefined

decimal strings

React Native

same interfaces as TypeScript, every method returns a Promise

null or undefined

decimal strings

In TypeScript and React Native, channel records and state items cross the bridge as opaque JSON bytes (Uint8Array) and must be persisted verbatim.

Choosing backends at run time

DeRecProtocol is generic over its five stores and transport, so by default the concrete backends are part of its type. To pick one at run time — Postgres in production, in-memory under test — use trait objects. The library implements every store trait for Box<T> and &mut T, and DeRecTransport additionally for Arc<T>:

A store behind an Arc does not compile: every store has &mut self methods, and Arc never yields &mut T. The transport is the one &self-only trait, and the one worth sharing across requests, since it is typically a pooled client.

The transport is a mailbox, not a request/response channel

DeRecTransport is not one of the five stores, but it is the sixth thing every integration implements and the one whose contract fails silently when misread.

Every peer has an address, and answering someone means posting to their address. A reply produced while handling an inbound message is not returned to the caller — it is handed to send, addressed to the peer's endpoint, while process() returns only the events describing what happened.

Where both sides are reachable services, that is all you need: the transport is a one-way push and an HTTP handler acknowledges rather than answers.

Such a deployment must answer on the connection the request arrived on, which means capturing what the protocol emits instead of pushing it:

  1. Build the protocol per request, with a transport that collects into a buffer instead of sending. The buffer must belong to exactly one exchange, or concurrent requests mix their replies.

  2. Return the collected message whose trace id matches the inbound envelope's. Every SDK exposes the same primitive for this: read_trace_id (Rust), Envelope.ReadTraceId (.NET), envelope.ReadTraceID (Go), envelope_read_trace_id (TypeScript/WASM), read_trace_id_from_envelope (C FFI).

  3. Deliver the rest of the buffer. One process call can emit several messages — admitting a Helper also republishes to every other Helper — so the remainder is genuine fan-out and still has to reach its own endpoint. Returning the whole buffer, or blindly returning its first entry, is the mistake to avoid.

A compiled Collector / split_reply pair is in the DeRecTransport rustdoc; both are about twenty lines.


Reference implementations

Backend
Location
Notes

SQLite

smoke-tests/sqlite/src/stores/

In-memory or on-disk. Seven tables: channels, replica_members and channel_links for the channel store, plus shares, secrets, user_secrets and protocol_state. Migrations 0001_init.sql and 0002_user_secrets_author.sql.

Postgres

smoke-tests/postgres/src/stores/

Same shape with Postgres column types. Smoke runs against the local docker container.

Both bindings implement all five traits durably over the same schema shape. They are intentionally small — a developer building a new backend (RocksDB, IndexedDB, a remote KV service, an in-process mock for tests) can fork either and substitute the storage primitives.

The Rust SDK chapter walks through wiring these into a DeRecProtocolBuilder, the .Net chapter shows the C# interface equivalent, the TypeScript and React Native chapters show the JS object equivalent, and the Go chapter shows the Go interface equivalent (constructed via protocol.New rather than a builder).

Last updated