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
Tip

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;
}
Note

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.

Note

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();
}
Important

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.

Tip

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.

Tip

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));
}
Note

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

  1. Keep records focused: Each record should represent a single concept
  2. Default to private: Make fields public only when you actually need to
  3. Use formulas: Let Adama compute derived values automatically instead of maintaining them manually
  4. Use methods for encapsulation: Put record-specific logic in methods
  5. Think about table design: Records in tables get automatic IDs and efficient querying
Previous Types