It's recommended that you understand the basics of sbp
before moving on because everything below relies on it.
In Group Income, we use a framework for building software out of something called "contract chains" (if you're familiar with how smart contracts in Ethereum work, this will make more sense to you).
A contract chain is an immutable linked-list of events. We use this sequence of events to build up a state. For example, in Group Income we have a "group contract" that represents everything happening within a single group. The first event in the contract defines and registers the contract itself, in this case, the specific instance of a group. Each subsequent event represents an action that happens within the group.
So for example, if we want to update the settings of a group, we do this:
// Example: Update a group minimum income to 150
await sbp('gi.actions/group/updateSettings', {
contractID: this.currentGroupId, data: { mincomeAmount: 150 }
})
This action accomplishes two things at once:
- It creates the event — an action — and wraps it in a
GIMessage
object. - It sends this
GIMessage
to the server - appending it to the contractcontractID
(in this case, the current group we're in)
Contracts can be thought of as distributed classes. When you create a contract, you create an instance, similarly to how instances in OOP can be created. Contracts have an internal state that is updated by actions. A contract can be a group, a user profile (identity), or any other thing. All current contracts can be found at
frontend/model/contracts/
.
GIMessages
, and in fact all data in Group Income, is referenced by its hash, and on the server, stored in a file with a file name that is equal to that hash. Retrieving that data then becomes a simple hash lookup, similar to how IPFS and the Dat Protocol work, except much simpler (no DHT is used).
All of the contract actions for all the contracts used in Group Income are defined in frontend/controller/actions/
.
When a client sends a GIMessage
to a contract stored on the server, that message is sent back (via websockets) to everyone who is subscribed to that contract, including the client that sent the message. Upon receiving this message/event, each client uses it to update their local copy of the contract state, per the process
function that is defined for that action. All of these process
functions can be found in frontend/model/contracts/
.
In Group Income, we integrate our contract framework with Vuex, and the logic for that integration can be found in frontend/model/state.js
.
This allows the UI to be automatically updated whenever an action is sent to the contract. Our framework even supports Vuex-like getters, that can be directly bound to the UI.
Whenever a message is received by a client, it is first processed through the Vuex action handleEvent
in frontend/model/state.js
.
From there is sent to our framework and contract logic. In most cases, a normal non-async function is called, call the process
function. This processes the message and applies the logic to update the state based on the action name and any data that is associated with it.
In our example above, the name of the contract action generated by sbp('gi.actions/group/updateSettings', ...)
is called 'gi.contracts/group/updateSettings'
, and its process
function is defined in frontend/model/contracts/group.js
:
'gi.contracts/group/updateSettings': {
validate: objectMaybeOf({
groupName: string,
groupPicture: string,
sharedValues: string,
mincomeAmount: numberRange(Number.EPSILON, Number.MAX_VALUE),
mincomeCurrency: string
}),
process ({ meta, data }, { state, getters }) {
for (const key in data) {
Vue.set(state.settings, key, data[key])
}
}
},
Note: Metadata is only included when contracts define a metadata key in their contract definition. It includes information about the action, such as when it was created and who created it.
Chelonia implements reference counting to automatically manage contract subscriptions. When the reference count is positive, a contract subscription is created, meaning that the contract will be synced and Chelonia will listen for new updates. When the reference count drops to zero, the contract will be automatically removed(*).
Subscribing to a contract and syncing all of its updates is done by calling 'chelonia/contract/retain'
:
For example:
sbp('chelonia/contract/retain', contractID)
// OR wait until it finishes syncing:
await sbp('chelonia/contract/retain', contractID)
This will subscribe us to the contract and begin listening for new updates.
When the contract is no longer needed, you can call 'chelonia/contract/release'
.
For example:
await sbp('chelonia/contract/release', contractID)
Normally, you should call retain
each time an event is about to happen that
requires receiving updates for a contract. You should then call release
when
that reason for subscribing no longer holds.
IMPORTANT: Each call to release
must have a corresponding call to retain
.
In other words, the reference count cannot be negative.
Three examples of this are:
- Writing to a contract. Writing to a contract requires being subscribed
to it, so you should call
retain
before sending an action to it. The contract can be released by callingrelease
after the writes have completed. - Subscribing to related contracts in side-effects. A common use case for
calling
retain
andrelease
is when a contract is related to other contracts. For example, you could have users that can be members of different groups. Every time a user joins a group, you would callretain
in the side-effect of that action, and then callrelease
when the group membership ends. - Logging a user in. If your application has users that are represented by
contracts, those contracts usually need to be subscribed during the life of
a session. This would be an example where you would have a call to
retain
when the account is created and then there could be norelease
call.
Chelonia maintains two different reference counts that you can directly control
using retain
and release
: ephemeral and non-ephemeral.
Non-ephemeral reference counts are stored in the root Chelonia state and are meant to be persisted. In the examples above, the examples of subscribing to related contracts and logging a user in would fall in this category. If Chelonia is restarted, you want those references to have the same value.
On the other hand, ephemeral reference counts work a differently. Those are
only stored in RAM and are meant to be used when restarting Chelonia should
not restore those counts. The example of writing to a contract would be one
of those cases. If Chelonia is restarted (for example, because a user refreshes
the page) and you're in the middle of writing to a contract, you would not want
that reference to persist because you'd have no way of knowing you have to call
release
afterwards, which would have the effect of that contract never being
removed.
All calls to retain
and release
use non-ephemeral references by default. To
use ephemeral references, pass an object with { ephemeral: true }
as the last
argument. Note that ephemeral retain
s are paired with ephemeral release
s,
and non-ephemeral retain
s are paired with non-ephemeral release
s (i.e.,
don't mix them).
For example,
// NOTE: `retain` must be _outside_ of the `try` block and immediately followed
// by it. This ensures that if `release` is called if and only if `retain`
// succeeds.
await sbp('chelonia/contract/retain', contractID, { ephemeral: true })
try {
// do something
} finally {
await sbp('chelonia/contract/release', contractID, { ephemeral: true })
}
In addition to retain
and release
, there is another selector that's
relevant: chelonia/contract/sync
. You use sync
to force fetch the latest
state from the server, or to create a subscription if there is none (this is
useful when bootstrapping your app: you already have a state, but Chelonia
doesn't know you should be subscribed to a contract). sync
doesn't affect
reference counts and you should always call sync
on contracts that have at
least one refeence. This means that you need to, at some point, have called
retain
on that contract first.
(...WIP...)
When subscribed to a Contract, the user is updated each time an action there is called, even if the action wasn't triggered by the user itself. (TODO: Add link/reference to where this happens)
So you don't need to worry about this for now, it just works 🔮.
(*) The actual mechanism is more involved than this, as there are some other reasons to listen for contract updates. For example, if contracts use foreign keys (meaning keys that are defined in other contracts), Chelonia may listen for events in those other contracts to keep keys in sync.
That's all for now! Feel free to dive even more deeply in the files mentioned so far and complement these docs with your discoveries.