Stable Structures
TL;DR
- 96 GiB of stable memory
- Persistent across upgrades
- Familiar API
- Must specify memory id
- No migrations per memory id
Stable structures are data structures with familiar APIs that allow write and read access to stable memory. Stable memory is a separate memory location from the heap that currently allows up to 96 GiB of binary storage. Stable memory persists automatically across upgrades.
Persistence on the Internet Computer (IC) is very important to understand. When a canister is upgraded (its code is changed after being initially deployed) its heap is wiped. This includes all global variables.
On the other hand, anything stored in stable memory will be preserved. Writing and reading to and from stable memory can be done with a low-level API, but it is generally easier and preferable to use stable structures.
Azle currently provides one stable structure called StableBTreeMap
. It's similar to a JavaScript Map and has most of the common operations you'd expect such as reading, inserting, and removing values.
Here's how to define a simple StableBTreeMap
:
import { nat8, StableBTreeMap, text } from 'azle/experimental';
let map = StableBTreeMap<nat8, text>(0);
This is a StableBTreeMap
with a key of type nat8
and a value of type text
. Unless you want a default type of any
for your key
and value
, then you must explicitly type your StableBTreeMap
with type arguments.
StableBTreeMap
works by encoding and decoding values under-the-hood, storing and retrieving these values in bytes in stable memory. When writing to and reading from a StableBTreeMap
, by default the stableJson
Serializable object
is used to encode JS values into bytes and to decode JS values from bytes. stableJson
uses JSON.stringify
and JSON.parse
with a custom replacer
and reviver
to handle many Candid
and other values that you will most likely use in your canisters.
You may use other Serializable objects
besides stableJson
, and you can even create your own. Simply pass in a Serializable object
as the second and third parameters to your StableBTreeMap
. The second parameter is the key
Serializable object
and the third parameter is the value
Serializable object
. For example, the following StableBTreeMap
uses the nat8
and text
CandidType objects
from Azle as Serializable objects
. These Serializable objects
will encode and decode to and from Candid bytes:
import { nat8, StableBTreeMap, text } from 'azle/experimental';
let map = StableBTreeMap<nat8, text>(0, nat8, text);
All CandidType
objects imported from azle
are Serializable objects
.
A Serializable object
simply has a toBytes
method that takes a JS value and returns a Uint8Array
, and a fromBytes
method that takes a Uint8Array
and returns a JS value.
Here's an example of how to create your own simple JSON
Serializable
:
export interface Serializable {
toBytes: (data: any) => Uint8Array;
fromBytes: (bytes: Uint8Array) => any;
}
export function StableSimpleJson(): Serializable {
return {
toBytes(data: any) {
const result = JSON.stringify(data);
return Uint8Array.from(Buffer.from(result));
},
fromBytes(bytes: Uint8Array) {
return JSON.parse(Buffer.from(bytes).toString());
}
};
}
This StableBTreeMap
also has a memory id
of 0
. Each StableBTreeMap
instance must have a unique memory id
between 0
and 254
. Once a memory id
is allocated, it cannot be used with a different StableBTreeMap
. This means you can't create another StableBTreeMap
using the same memory id
, and you can't change the key or value types of an existing StableBTreeMap
. This problem will be addressed to some extent.
Here's an example showing all of the basic StableBTreeMap
operations:
import {
bool,
Canister,
nat64,
nat8,
Opt,
query,
StableBTreeMap,
text,
Tuple,
update,
Vec
} from 'azle/experimental';
const Key = nat8;
type Key = typeof Key.tsType;
const Value = text;
type Value = typeof Value.tsType;
let map = StableBTreeMap<Key, Value>(0);
export default Canister({
containsKey: query([Key], bool, (key) => {
return map.containsKey(key);
}),
get: query([Key], Opt(Value), (key) => {
return map.get(key);
}),
insert: update([Key, Value], Opt(Value), (key, value) => {
return map.insert(key, value);
}),
isEmpty: query([], bool, () => {
return map.isEmpty();
}),
items: query([], Vec(Tuple(Key, Value)), () => {
return map.items();
}),
keys: query([], Vec(Key), () => {
return Uint8Array.from(map.keys());
}),
len: query([], nat64, () => {
return map.len();
}),
remove: update([Key], Opt(Value), (key) => {
return map.remove(key);
}),
values: query([], Vec(Value), () => {
return map.values();
})
});
With these basic operations you can build more complex CRUD database applications:
import {
blob,
Canister,
ic,
Err,
nat64,
Ok,
Opt,
Principal,
query,
Record,
Result,
StableBTreeMap,
text,
update,
Variant,
Vec
} from 'azle/experimental';
const User = Record({
id: Principal,
createdAt: nat64,
recordingIds: Vec(Principal),
username: text
});
type User = typeof User.tsType;
const Recording = Record({
id: Principal,
audio: blob,
createdAt: nat64,
name: text,
userId: Principal
});
type Recording = typeof Recording.tsType;
const AudioRecorderError = Variant({
RecordingDoesNotExist: Principal,
UserDoesNotExist: Principal
});
type AudioRecorderError = typeof AudioRecorderError.tsType;
let users = StableBTreeMap<Principal, User>(0);
let recordings = StableBTreeMap<Principal, Recording>(1);
export default Canister({
createUser: update([text], User, (username) => {
const id = generateId();
const user: User = {
id,
createdAt: ic.time(),
recordingIds: [],
username
};
users.insert(user.id, user);
return user;
}),
readUsers: query([], Vec(User), () => {
return users.values();
}),
readUserById: query([Principal], Opt(User), (id) => {
return users.get(id);
}),
deleteUser: update([Principal], Result(User, AudioRecorderError), (id) => {
const userOpt = users.get(id);
if ('None' in userOpt) {
return Err({
UserDoesNotExist: id
});
}
const user = userOpt.Some;
user.recordingIds.forEach((recordingId) => {
recordings.remove(recordingId);
});
users.remove(user.id);
return Ok(user);
}),
createRecording: update(
[blob, text, Principal],
Result(Recording, AudioRecorderError),
(audio, name, userId) => {
const userOpt = users.get(userId);
if ('None' in userOpt) {
return Err({
UserDoesNotExist: userId
});
}
const user = userOpt.Some;
const id = generateId();
const recording: Recording = {
id,
audio,
createdAt: ic.time(),
name,
userId
};
recordings.insert(recording.id, recording);
const updatedUser: User = {
...user,
recordingIds: [...user.recordingIds, recording.id]
};
users.insert(updatedUser.id, updatedUser);
return Ok(recording);
}
),
readRecordings: query([], Vec(Recording), () => {
return recordings.values();
}),
readRecordingById: query([Principal], Opt(Recording), (id) => {
return recordings.get(id);
}),
deleteRecording: update(
[Principal],
Result(Recording, AudioRecorderError),
(id) => {
const recordingOpt = recordings.get(id);
if ('None' in recordingOpt) {
return Err({ RecordingDoesNotExist: id });
}
const recording = recordingOpt.Some;
const userOpt = users.get(recording.userId);
if ('None' in userOpt) {
return Err({
UserDoesNotExist: recording.userId
});
}
const user = userOpt.Some;
const updatedUser: User = {
...user,
recordingIds: user.recordingIds.filter(
(recordingId) =>
recordingId.toText() !== recording.id.toText()
)
};
users.insert(updatedUser.id, updatedUser);
recordings.remove(id);
return Ok(recording);
}
)
});
function generateId(): Principal {
const randomBytes = new Array(29)
.fill(0)
.map((_) => Math.floor(Math.random() * 256));
return Principal.fromUint8Array(Uint8Array.from(randomBytes));
}
The example above shows a very basic audio recording backend application. There are two types of entities that need to be stored, User
and Recording
. These are represented as Candid
records.
Each entity gets its own StableBTreeMap
:
import {
blob,
Canister,
ic,
Err,
nat64,
Ok,
Opt,
Principal,
query,
Record,
Result,
StableBTreeMap,
text,
update,
Variant,
Vec
} from 'azle/experimental';
const User = Record({
id: Principal,
createdAt: nat64,
recordingIds: Vec(Principal),
username: text
});
type User = typeof User.tsType;
const Recording = Record({
id: Principal,
audio: blob,
createdAt: nat64,
name: text,
userId: Principal
});
type Recording = typeof Recording.tsType;
const AudioRecorderError = Variant({
RecordingDoesNotExist: Principal,
UserDoesNotExist: Principal
});
type AudioRecorderError = typeof AudioRecorderError.tsType;
let users = StableBTreeMap<Principal, User>(0);
let recordings = StableBTreeMap<Principal, Recording>(1);
Notice that each StableBTreeMap
has a unique memory id
. You can begin to create basic database CRUD functionality by creating one StableBTreeMap
per entity. It's up to you to create functionality for querying, filtering, and relations. StableBTreeMap
is not a full-featured database solution, but a fundamental building block that may enable you to achieve more advanced database functionality.
Demergent Labs plans to deeply explore database solutions on the IC in the future.
Caveats
float64 values
It seems to be only some float64
values cannot be successfully stored and retrieved with a StableBTreeMap
using stableJson
because of this bug with JSON.parse
: https://github.com/bellard/quickjs/issues/206
CandidType Performance
Azle's Candid encoding/decoding implementation is currently not well optimized, and Candid may not be the most optimal encoding format overall, so you may experience heavy instruction usage when performing many StableBTreeMap
operations in succession. A rough idea of the overhead from our preliminary testing is probably 1-2 million instructions for a full Candid encoding and decoding of values per StableBTreeMap
operation.
For these reasons we recommend using the stableJson
Serializable object
(the default) instead of CandidType
Serializable objects
.
Migrations
Migrations must be performed manually by reading the values out of one StableBTreeMap
and writing them into another. Once a StableBTreeMap
is initialized to a specific memory id
, that memory id
cannot be changed unless the canister is completely wiped and initialized again.
Canister
Canister
values do not currently work with the default stableJson
implementation. If you must persist Canister
s, consider using the Canister
CandidType object
as your Serializable object
in your StableBTreeMap
, or create a custom replacer
or reviver
for stableJson
that handles Canister
.