State
In Reboot, durable state data types are where your development begins. This page discusses how you (1) declare state types and (2) how you (and your users if they are intended to be publicly consumable) can then construct and lookup instances of your states.
Schemas and interfaces
Reboot uses an interface definition language (IDL) to define your APIs, including the schemas of your durable state data types and the methods you can call on those states.
We currently use protocol buffers as our IDL (if you're interested in OpenAPI support, please reach out to us on Discord). From protobuf, we generate rich language-specific client and server code.
To declare a state data type called Hello
, put the following in a
.proto
file:
message Hello {
option (rbt.v1alpha1.state) = {
};
repeated string messages = 1;
}
Reboot uses standard Protobuf definitions, but take note of the rbt
options (aka, annotations).
In the above example, rbt.v1alpha1.state
declares that message Hello
is a Reboot durable state data type.
In addition to defining the state data type, you'll also need to define operations for that type, for example:
service HelloMethods {
// Returns the current list of recorded messages.
rpc Messages(MessagesRequest) returns (MessagesResponse) {
option (rbt.v1alpha1.method).reader = {
};
}
// Adds a new message to the list of recorded messages.
rpc Send(SendRequest) returns (SendResponse) {
option (rbt.v1alpha1.method).writer = {
};
}
}
The current convention is that a state's interface has the same name
as the state with the suffix Methods
.
For each of the rpc
methods that you declare, you use the
rbt.v1alpha1.method
annotation to specify the method
kind.
Depending on its kind, a method might be able to only read (e.g.,
reader
) or both read and write (e.g.,
writer
) the state.
In the example above, Hello
has just one reader
and one writer
method,
but it can have any number of reader
, writer
,
transaction
, and workflow
methods.
To learn more about how you implement your state data type's methods see the page on servicers.
Code generation
Code generation is a core part of working with Reboot.
Reboot has Protobuf compiler plugins that generate:
- Type-safe, language-specific gRPC interfaces for clients and servers, including:
- React client APIs for streaming, reactive access to your data.
- Python and TypeScript client APIs.
- Python and TypeScript backend server APIs.
- A backwards-compatible schema that is automatically persisted and replicated by Reboot.
Reboot provides the rbt
CLI tool to automatically generate code from
your .proto
file and reload your application when your code
changes. You can also choose to use an existing build system to
generate code by using Reboot's Protobuf plugins directly.
Construct
You construct an instance of your durable state data types by
using the construct()
builder method followed by a constructor,
i.e., one of your methods explicitly marked as a constructor or
implicitly acting as a constructor to initialize the state. For
example, on a Greeter
state type with a constructor called Create
:
- Python
- TypeScript
greeter, response = await Greeter.construct().Create(
context,
title='Dr',
name='Jonathan',
adjective='best',
)
const [greeter, response] = await Greeter.construct().create(context, {
title: "Dr",
name: "Jonathan",
adjective: "Best",
});
The first element in the tuple returned is a reference that you can
use to make other method calls (same thing returned from calling
lookup()
) and the second element is the response from calling the
method, in the above example, a response from calling Create
.
IDs
Every instance of your durable state data types has a unique ID. The ID can be used to lookup an instance in order to call methods on it.
By default, construct()
will generate a new unique ID (a
UUID)
for the constructed state. Alternatively, you can specify an ID
explicitly upon construction. Here is an example of constructing an
Account
with an explicit ID using the Open
constructor:
- Python
- TypeScript
account, _ = await Account.construct(id=new_account_id).Open(
context,
customer_name=request.customer_name,
)
const [account, _] = await Account.construct({ id: newAccountId }).open(
context,
{
customerName: request.customerName,
}
);
You can get the ID of the current instance from within a state method
via the passed-in context
, in the field state_id
(in Python) or
stateId
(in TypeScript).
Partitioning
Beyond identifiying an instance of a state data type, the ID is also used to automatically partition your states across CPU cores. In that way, you can think of an ID like a primary key in a database that is used to shard your data.
Singletons
Sometimes you might want a state data type to act as a "singleton" where there is a single well-known ID that is always used. But be warned, to avoid making the singleton a scalability bottleneck you should avoid storing too much data in a single state data type.
You'll probably want to construct your singletons in the initialize
function that you pass to Application
.
Constructors
You may designate writers
or
transactions
as explicit constructors by giving
them an extra annotation.
If a state data type doesn't specify any explicit constructors, new
state instances of that type must be constructed by calling a writer
or transaction
method which acts as an implicit constructor. In
this case, consult the state data type's documentation as it may
recommend using a particular method for implicit construction.
State data types that have explicitly designated constructor methods must be initialized by a call to one of those methods. Calls to other methods on an uninitialized state will raise an error. Once a state has been created, calls to explicit constructors on that state are no longer valid and will raise an error (the type system will also prevent you from doing so in the first place).
For example, to designate that the Open
method is an explicit
constructor for Account
:
rpc Open(OpenRequest) returns (OpenResponse) {
option (rbt.v1alpha1.method).writer = {
constructor: {},
};
}
Lookup
You can lookup an instance of your state using lookup()
, which
returns a reference that you can use to call a method on the state.
You can think of a reference like a handle (and it's similar to a
"stub" in gRPC parlance). The actual type of reference returned from
calling .lookup()
is a WeakReference
, where "weak" indicates that
the state may not exist. Here's an example:
- Python
- TypeScript
from_account = Account.lookup(request.from_account_id)
const fromAccount = Account.lookup(request.fromAccountId);