State
In Reboot, durable data structures are built out of state data types. This page discusses how to (1) declare state types and (2) how to construct and get a reference to an instance of your states.
State schemas and interfaces
With Reboot you define your API first. If you're using TypeScript you can use Zod for this, but for Python we currently only support protocol buffers (Pydantic support coming soon!)
From either your Zod schemas or protobuf, Reboot generates rich language-specific client and server code.
Zod (TypeScript only)
To declare a state data type called Hello, put the following in a
.ts
file:
export const api = {
Hello: {
state: {
messages: z.array(z.string()).default(() => []).meta({ tag: 1 }),
},
methods: {
messages: {
kind: "reader",
request: {},
response: {
messages: z.array(z.string()).meta({ tag: 1 }),
},
},
send: {
kind: "writer",
request: {
message: z.string().meta({ tag: 1 }),
},
response: z.void(),
},
},
},
};
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.
Protobuf
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 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
Reboot generates:
- 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 Zod schemas or .proto
files and reloads your application when your API
changes.
State IDs
Every instance of state has a unique ID. The ID can be used to
passed to ref()
to get a reference to an instance that you can use to
call a method on the state:
- Python
- TypeScript
from_account = Account.ref(request.from_account_id)
const fromAccount = Account.ref(request.fromAccountId);
You can think of a reference like a handle (and it's similar to a "stub" in gRPC parlance).
The type of reference returned from
calling .ref()
is a WeakReference
, where "weak" indicates that
the state may or may not exist.
Within one of your servicer methods you can get the ID of the current
state instance via context.state_id
(Python), context.stateId
(TypeScript).
Partitioning
Beyond identifying 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 can construct your singletons in the
initialize
function
that you pass to Application
.
Constructing instances of state
You construct an instance of your state data types either implicitly
or explicitly, depending on whether or not you have explicitly
designated certain methods as constructors in your .proto
, or a
factory, in your Zod schema.
If your state can be default constructed, prefer implicit construction! Only use explicit construction if you have some initialization that must be performed.
Implicit construction
If a state data type doesn't specify any explicit constructors, new
state instances of that type will be implicitly constructed when
you call a writer
or transaction
method. For example:
- Python
- TypeScript
counter = Counter.ref(id)
# Will implicitly construct if not already constructed!
await counter.Increment(context)
const counter = Counter.ref(id);
// Will implicitly construct if not already constructed!
await counter.increment(context);
A state will not be implicitly constructed when you call a reader
method so you may need to properly ensure you've constructed your
state by calling a writer
or transaction
first (which may just be
a no-op method that acts like a "constructor" but can be called
multiple times unlike explicit constructors which can only be called
once).
Explicit construction
If you've explicitly defined a constructor/factory, then you must explicitly use it for constructing an instance.
Here's an example of both using Zod and a .proto
for designating
that the Open
method is an explicit constructor for Account
:
- Python
- TypeScript
rpc Open(OpenRequest) returns (OpenResponse) {
option (rbt.v1alpha1.method).writer = {
constructor: {},
};
}
export const api = {
Account: {
// ...
methods: {
// ...
open: {
kind: "writer",
// Must use this function to construct an instance of `Account`.
factory: {},
// ...
},
},
},
};
Constructors/factories are exposed as static or class methods in the generated
code, and return a tuple where the first element is a reference to
the constructed state (same thing returned from
ref()
) and the second is the response
from calling the method. For example:
- Python
- TypeScript
account, response = await Account.Open(
context,
customer_name=request.customer_name,
)
const [account, response] = await Account.open(context, {
customerName: request.customerName,
});
You can only call an explicit constructor once! Otherwise you'll get
back a StateAlreadyConstructed
error. This can be useful if you're
trying to check if you've already constructed the state, but you may
also consider using implicit construction and calling a no-op method
to ensure that the state has been constructed.
Calling an explicit constructor will generate a new unique ID (a
UUID)
for the constructed state. Alternatively, you can specify an ID
explicitly upon construction by passing it after context
. Here is
an example of constructing an Account
with an explicit ID using the
Open
constructor:
- Python
- TypeScript
account, _ = await Account.Open(
context,
new_account_id,
customer_name=request.customer_name,
)
const [account, response] = await Account.open(context, newAccountId, {
customerName: request.customerName,
});
Idempotent construction
You can call explicit constructors using
idempotently()
external to a Reboot
application (i.e., using an ExternalContext
):
- Python
- TypeScript
account, response = await Account.idempotently().Open(
context,
customer_name=request.customer_name,
)
const [account, response] = await Account.idempotently().open(context, {
customerName: request.customerName,
});