Records and Messages
Adama has two kinds of structured types, and the distinction between them matters more than you might think. Records are your persistent state — they live in the document, survive restarts, and have privacy controls. Messages are ephemeral — they're the shape of data flowing in from clients, existing only long enough to be processed.
They look almost identical in syntax, which I sometimes think was a mistake (it confuses people), but they serve fundamentally different purposes.
Records vs Messages
| Aspect | Records | Messages |
|---|---|---|
| Purpose | Persistent document state | Ephemeral communication |
| Lifetime | Stored in document, survives restarts | Exists only during processing |
| Methods | Can have methods | Can have methods |
| Tables | Can be stored in tables | Cannot be stored in tables |
| Privacy | Supports privacy policies | No privacy (client-controlled) |
| Usage | Document structure | Channels, @web handlers |
Think of records as your database schema and messages as your API request shapes. That mental model will carry you pretty far.
Defining Records
Records define the shape of persistent data that lives in your document. Use the record keyword followed by the name and field declarations.
record User {
public int id;
public string name;
private principal owner;
}
Records can be used as:
- Document-level fields
- Table row types
- Nested within other records
Field Privacy Modifiers
Every field in a record has a privacy modifier that controls visibility to connected clients. I'll go deeper on privacy in the privacy chapter, but here's the quick reference.
| Modifier | Visibility |
|---|---|
public |
Visible to all connected clients |
private |
Never sent to clients |
viewer_is<field> |
Visible only when viewer equals the principal field |
use_policy<name> |
Visible when the named policy returns true |
record Player {
public int id;
public string name;
private principal owner;
private int internalScore;
viewer_is<owner> int secretBalance;
}
Fields with Default Values
Fields can have default values that are assigned when a record instance is created.
record GameConfig {
int maxPlayers = 4;
int roundTime = 60;
double difficulty = 1.0;
string gameMode = "standard";
bool allowSpectators = true;
maybe<string> customMessage = "Welcome!";
}
When you create a record instance (via table insert or field initialization), any unspecified fields receive their default values. This is straightforward but saves a lot of boilerplate.
record GameConfig {
int maxPlayers = 4;
int roundTime = 60;
double difficulty = 1.0;
string gameMode = "standard";
bool allowSpectators = true;
maybe<string> customMessage = "Welcome!";
}
table<GameConfig> configs;
@construct {
// Uses all defaults: maxPlayers=4, roundTime=60, etc.
configs <- {};
// Override some defaults
configs <- {maxPlayers: 8, difficulty: 2.0};
}
Computed Fields (Formulas)
Records can contain computed fields using the formula keyword. These are reactive — they automatically recalculate when their dependencies change. This is where the spreadsheet metaphor starts to shine.
record Item {
public int quantity;
public double unitPrice;
public formula totalPrice = quantity * unitPrice;
}
The totalPrice updates automatically whenever quantity or unitPrice changes. You never write recalculation logic. It just happens.
record Rectangle {
public int width;
public int height;
public formula area = width * height;
public formula perimeter = 2 * (width + height);
public formula isSquare = width == height;
}
Formulas can reference other formulas, creating chains of reactive computation. Adama handles the dependency tracking automatically — you don't need to think about evaluation order.
Nested Records
Records can contain other records, enabling hierarchical data structures.
record Address {
public string street;
public string city;
public string country;
}
record Person {
public string name;
public Address homeAddress;
public Address workAddress;
}
Records can even contain tables of other records.
record Comment {
public string text;
public principal author;
public long timestamp;
}
record Post {
public string title;
public string content;
table<Comment> comments;
}
Indexing Fields
When records are stored in tables, you can declare indexes on fields to enable efficient lookups. Use the index directive inside the record definition.
record Player {
public int score;
public string name;
public principal owner;
index score; // Enables fast lookups by score
index owner; // Enables fast lookups by owner
}
table<Player> players;
With indexes defined, where clauses on those fields become fast:
record Player {
public int score;
public string name;
public principal owner;
index score;
index owner;
}
table<Player> players;
message EmptyMsg {}
channel lookupExample(EmptyMsg m) {
// Fast lookup using the index
let highScorers = iterate players where score > 100;
let myPlayers = iterate players where owner == @who;
}
You can declare multiple indexes on different fields. Indexes work with int, long, string, bool, enum, principal, and datetime field types.
Indexes consume additional memory and add overhead on inserts/updates. Only index fields that you frequently query with where clauses. Don't just index everything — that's the siren song of premature optimization.
Record Methods
Records can have methods that operate on the record's data. I like methods because they keep behavior close to the data it operates on.
Basic Methods
record Counter {
public int value = 0;
method increment() {
value++;
}
method add(int amount) {
value += amount;
}
method reset() {
value = 0;
}
}
Call methods on record instances:
record Counter {
public int value = 0;
method increment() {
value++;
}
method add(int amount) {
value += amount;
}
method reset() {
value = 0;
}
}
Counter myCounter;
@construct {
myCounter.increment(); // value = 1
myCounter.add(5); // value = 6
myCounter.reset(); // value = 0
}
Methods with Return Values
Methods can return values using the arrow syntax.
record Item {
public int quantity;
public double price;
method total() -> double {
return quantity * price;
}
method discountedTotal(double discountPercent) -> double {
return total() * (1.0 - discountPercent);
}
}
Readonly Methods
Mark methods as readonly when they only read data without modifying the record. This is required for methods used in formulas — the compiler won't let you call a mutating method from a reactive context.
record Circle {
public double radius;
method area() -> double readonly {
return 3.14159 * radius * radius;
}
method circumference() -> double readonly {
return 2 * 3.14159 * radius;
}
// Can use readonly methods in formulas
public formula displayArea = area();
}
Methods used in formula expressions must be marked readonly. The compiler enforces this. Non-readonly methods in reactive contexts would break the whole purity model.
Method Overloading
Records support method overloading — multiple methods with the same name but different parameters.
record Calculator {
int result = 0;
method compute() {
// No-op version
}
method compute(int x) -> int {
result = x * 2;
return result;
}
method compute(int x, int y) -> int {
result = x + y;
return result;
}
}
Defining Messages
Messages define the shape of data sent to your document from clients. They're used with channels and @web handlers.
message CreateUser {
string name;
int age;
}
message UpdateProfile {
string displayName;
maybe<string> bio;
maybe<string> avatarUrl;
}
Messages with Channels
Channels receive messages from connected clients. The message type defines what data the client must send.
record ChatEntry {
public principal who;
public string text;
public datetime when;
}
table<ChatEntry> _chat;
message Say {
string text;
}
channel say(Say msg) {
// msg.text contains the client's message
_chat <- {who: @who, text: msg.text, when: Time.datetime()};
}
Messages with @web Handlers
Messages define the request body shape for @web PUT/POST handlers.
message LoginRequest {
string username;
string password;
}
function validateCredentials(string username, string password) -> bool {
return username == "admin" && password == "secret";
}
@web put /login (LoginRequest req) {
// Validate credentials using req.username and req.password
if (validateCredentials(req.username, req.password)) {
return {html: "Login successful"};
}
return {html: "Invalid credentials"};
}
Nested Message Structures
Messages can contain other messages and arrays for complex data shapes.
message Address {
string street;
string city;
string zip;
}
message OrderItem {
int productId;
int quantity;
}
message CreateOrder {
Address shippingAddress;
Address billingAddress;
OrderItem[] items;
maybe<string> couponCode;
}
Message Field Types
Messages support a wide variety of field types:
enum MyEnum { A, B, C }
message OtherMessage { int val; }
message CompleteExample {
// Primitives
int count;
long bigNumber;
double price;
bool active;
string name;
// Maybe types
maybe<int> optionalCount;
maybe<string> optionalName;
// Enums
MyEnum status;
maybe<MyEnum> optionalStatus;
// Nested messages
OtherMessage nested;
OtherMessage[] nestedArray;
// Labels
label nextState;
maybe<label> optionalState;
}
Methods in Messages
Like records, messages can also have methods.
message Point {
int x;
int y;
method distanceFromOrigin() -> double {
// Math.sqrt returns complex, extract the real part
return Math.sqrt(x * x + y * y).real();
}
method manhattanDistance() -> int {
return Math.abs(x) + Math.abs(y);
}
}
The lossy Keyword
Fields marked lossy in messages are input-only and not tracked for change detection. They're useful for fields that serve as lookup keys rather than updatable data:
message UpdateSessionClass {
int id lossy;
int session_id lossy;
maybe<string> name;
}
The Update Message Pattern and <- Merge
This pattern is one I use constantly in production. For partial updates, you create messages with maybe<T> fields and use the <- merge operator:
record BusinessHour {
public bool available;
public time open_time;
public time close_time;
}
table<BusinessHour> _hours;
message UpdateBusinessHour {
int target_id lossy;
maybe<bool> available;
maybe<time> open_time;
maybe<time> close_time;
}
// Merge only the fields that are present in the message
channel update_hours(UpdateBusinessHour update) {
if ((iterate _hours where id == update.target_id)[0] as hour) {
hour <- update; // Only fields with values are applied
}
}
When merging with <-, maybe<T> fields that have no value are skipped — only populated fields are applied to the record. This is how you do PATCH-style updates without writing a bunch of if (field.has()) { ... } code.
For every record that needs updating, create a matching message with maybe<T> fields. This pattern enables efficient partial updates and I've used it in every production Adama app I've built.
Field Annotations
Message and record fields support annotations that provide UI hints:
int(Hidden) id; // UI hint: hidden field
string(Label="Address 1") address1; // UI hint: label
string(Title) name; // UI hint: title field
string(Large) staff_notes; // UI hint: large text area
int(Label="Class", Select="/classes") class_id; // UI hint: select from list
Message Validation with @parsed
Messages can include a @parsed event handler that runs immediately after the message is deserialized. This is where you validate input and reject garbage before your channel handler ever sees it.
message UserInput {
int value;
string name;
@parsed {
// Validate the input
if (value < 0) {
abort; // Reject negative values
}
if (name.length() == 0 || name.length() > 100) {
abort; // Reject invalid name length
}
}
}
The @parsed handler can also normalize data:
message Coordinates {
int x;
int y;
@parsed {
// Normalize to absolute values
x = x.abs();
y = y.abs();
// Validate range after normalization
if (x > 1000 || y > 1000) {
abort;
}
}
}
When a message's @parsed handler calls abort, the channel handler never runs and the client receives an error response. Clean separation between validation and business logic.
Use @parsed for validation that's intrinsic to the message itself — things like "this number can't be negative" or "this string can't be empty." Use channel handler guards for context-dependent validation like checking game state or user permissions.
Anonymous Records and Messages
Adama supports anonymous (inline) structured values without explicitly defining a record or message type. These are useful for quick one-off data when you don't want to define a whole type.
Inline Object Literals
Create structured values inline using brace notation.
record Thing {
public int x;
public int y;
public double z;
}
table<Thing> things;
@construct {
// Inline anonymous objects
things <- {x: 1, y: 2};
things <- {x: 3, y: 4, z: 5.0};
// Array of anonymous objects
things <- [{x: 10, y: 20}, {x: 30, y: 40}];
}
Field Shorthand
When a variable has the same name as the field, use shorthand syntax. A small ergonomic win.
record PlayerRec {
public string name;
public int score;
}
table<PlayerRec> players;
procedure addPlayer(string name, int score) {
// Instead of: players <- {name: name, score: score};
players <- {name, score}; // Shorthand when variable names match
}
Mix shorthand and explicit values:
record ItemRec {
public string name;
public int quantity;
public double price;
}
table<ItemRec> items;
procedure createItem(string name) {
int defaultQuantity = 1;
double defaultPrice = 0.0;
items <- {
name, // Shorthand
quantity: defaultQuantity, // Explicit
price: defaultPrice // Explicit
};
}
Anonymous Types in Iteration
Anonymous objects work well with foreach loops:
int total = 0;
@construct {
foreach (point in [{x: 1, y: 2}, {x: 3, y: 4}, {x: 5, y: 6}]) {
total += point.x + point.y;
}
}
Policy Definitions in Records
Policies provide fine-grained access control for record fields. They're boolean expressions that determine visibility. I think of them as little gatekeepers — each one answers the question "should this viewer see this data?"
Defining Policies
Define policies inside a record using the policy keyword.
record Card {
private principal owner;
private int secretValue;
use_policy<visible> int value;
policy visible {
return @who == owner;
}
}
The use_policy<visible> modifier means the value field is only sent to clients when the visible policy returns true for that viewer.
Multiple Policies
Fields can require multiple policies to all return true.
record SensitiveData {
private principal owner;
private bool isPublished;
use_policy<isOwner, isActive> int secretScore;
policy isOwner {
return @who == owner;
}
policy isActive {
return isPublished;
}
}
The require Directive
Use require to apply a policy to the entire record. If the policy returns false, the entire record is hidden from that viewer — not just individual fields, but its very existence.
record PrivateNote {
private principal owner;
public string title;
public string content;
policy isOwner {
return @who == owner;
}
require isOwner; // Entire record hidden if not owner
}
viewer_is Shorthand
For the common case of "only visible to owner", use viewer_is<field>:
record PlayerHand {
private principal player;
// Only the player can see their own hand
viewer_is<player> list<int> cards;
// Everyone can see card count
public int cardCount;
}
This is equivalent to creating a policy that checks @who == player and applying it with use_policy. Shorthand for the most common pattern.
Complete Privacy Example
Here's a more involved example showing various privacy patterns working together:
record BankAccount {
private principal owner;
private string accountNumber;
// Public: anyone can see
public string bankName;
// Private: never sent to clients
private string internalNotes;
// Only owner sees balance
viewer_is<owner> double balance;
// Custom policy for transaction history
use_policy<canViewHistory> list<string> transactions;
policy canViewHistory {
return @who == owner && balance > 0;
}
// Require owner to see entire record
// Without this, non-owners would see bankName only
// With this, non-owners see nothing
// require isOwner;
policy isOwner {
return @who == owner;
}
}
Converting Between Records and Messages
Use @convert<T> to convert between compatible record and message types.
record Player {
int id;
string name;
int score;
}
message PlayerInfo {
int id;
string name;
}
Player p;
table<Player> players;
@construct {
p.name = "Alice";
p.score = 100;
// Convert record to message (drops extra fields)
PlayerInfo info = @convert<PlayerInfo>(p);
// Convert table iteration to message array
PlayerInfo[] allInfo = @convert<PlayerInfo>(iterate players);
// Convert maybe<record> to maybe<message>
maybe<PlayerInfo> maybeInfo = @convert<PlayerInfo>(@maybe(p));
}
When converting, only fields that exist in both types are copied. Extra fields in the source are ignored, and missing fields in the target get default values. It's structural matching by name — nothing fancy, but it works well in practice.
Best Practices
When to Use Records
- Storing persistent game state (player data, game board, scores)
- Document-level configuration
- Any data that needs to survive document restarts
- Data that requires privacy controls
When to Use Messages
- Channel inputs from clients
- @web request bodies
- Temporary data structures for computation
- API response shapes
Design Tips
- Keep records focused: Each record should represent a single concept
- Default to private: Make fields
publiconly when you actually need to - Use formulas: Let Adama compute derived values automatically instead of maintaining them manually
- Use methods for encapsulation: Put record-specific logic in methods
- Think about table design: Records in tables get automatic IDs and efficient querying