Cargo: The Universal Data Container
Cargo is a powerful and flexible data structure designed to manage application state, from simple counters to
complex, nested data hierarchies. At its core, Cargo is a super-charged wrapper around a standard Groovy Map,
providing a rich API for data manipulation, state management, and seamless integration with other Spaceport systems
like Documents and Launchpad.
Think of a Cargo object as a universal container. It can act as a simple value holder, a nested map, a counter, a toggle, or a set. This versatility makes it an indispensable tool for handling form data, user sessions, application settings, and reactive UI components.
This document will start by exploring Cargo in its simplest form—as a local object—before moving on to its more advanced features like persistence and reactivity.
# Core Concepts: Using Cargo as a Local Object
The easiest way to understand Cargo is to use it as a local variable within your source modules. It provides a clean, fluent API for creating, reading, updating, and deleting data without needing any external connections.
## Construction
You can create a Cargo object in several ways, depending on your initial data needs.
- Empty Cargo: For building up data dynamically.
// Creates an empty Cargo object, ready to hold data.
def userData = new Cargo()
- Single-Value Cargo: When you just need to track a single piece of data, like a number or a string.
// Creates a Cargo object holding a single integer value.
def pageCounter = new Cargo(0)
// Creates a Cargo object holding a single string value.
def statusMessage = new Cargo('Initialized.')
- From a Map: To instantly convert an existing
Mapinto aCargoobject.
// Creates a Cargo object pre-populated with key-value pairs.
def settings = new Cargo([
darkMode: false,
notifications: true,
volume: 75
])
## Basic Getters and Setters
Cargo provides intuitive methods for reading and writing data. It can operate as a single-value container or as a map-like structure with nested nodes.
### Single-Value Operations
When a Cargo object is treated as a single-value holder, the get() and set() methods work on an implicit
internal property.
def counter = new Cargo(10)
// Get the current value
def currentValue = counter.get() // Returns 10
// Set a new value
counter.set(11)
println counter.get() // Prints 11
### Map-Like Operations and Deep Nodes
The real power of Cargo shines when managing structured data. You can set and retrieve values at any depth using a
.-separated path. If the path doesn't exist, Cargo creates it for you on the fly.
def user = new Cargo()
// Set simple key-value pairs
user.set('username', 'jeremy')
user.set('level', 99)
// Set a deeply nested value. The 'profile' and 'prefs' nodes are created automatically.
user.set('profile.prefs.theme', 'dark')
// Get the values back
def username = user.get('username') // Returns 'jeremy'
def theme = user.get('profile.prefs.theme') // Returns 'dark'
### Property Access with Dot Notation
For a more idiomatic Groovy experience, you can also use standard dot notation to access and assign properties. This makes your code cleaner and more readable.
def user = new Cargo()
// Set values using dot notation
user.username = 'jeremy'
user.profile.prefs.theme = 'dark'
// Get values using dot notation
println user.username // Prints 'jeremy'
println user.profile.prefs.theme // Prints 'dark'
// Each node in the path is also a Cargo object
println user.profile.class.name // Prints spaceport.computer.memory.virtual.Cargo
# Cargo in Action: Counters, Toggles, and Sets
Beyond basic key-value storage, Cargo includes specialized methods for common state management patterns.
## As a Counter
You can easily increment or decrement numeric values, making Cargo perfect for counters, scores, or inventory management.
def stats = new Cargo([ pageViews: 100, likes: 5 ])
// Increment the pageViews by 1
stats.inc('pageViews') // pageViews is now 101
// Increment likes by a specific amount
stats.inc('likes', 10) // likes is now 15
// Decrement pageViews
stats.dec('pageViews') // pageViews is now 100
For single-value Cargo objects, you can omit the path:
def counter = new Cargo(0)
counter.inc() // Value is now 1
## As a Toggle
You can flip the boolean state of a property with the toggle() method.
def settings = new Cargo([ darkMode: false ])
settings.toggle('darkMode')
println settings.get('darkMode') // Prints true
settings.toggle('darkMode')
println settings.get('darkMode') // Prints false
## As a Set
Cargo can manage a unique list of items, which is useful for things like tags, favorites, or user roles.
def article = new Cargo()
// Add items to the 'tags' list. Duplicates are ignored.
article.addToSet('tags', 'groovy')
article.addToSet('tags', 'spaceport')
article.addToSet('tags', 'groovy') // This one is ignored
println article.get('tags') // Prints ['groovy', 'spaceport']
// Check if an item exists in the set
boolean hasTag = article.contains('tags', 'spaceport') // Returns true
// Remove an item from the set
article.takeFromSet('tags', 'groovy')
println article.get('tags') // Prints ['spaceport']
# Advanced Data Manipulation
Cargo provides several other utility methods for managing the data within it, from removing data to checking for
its existence.
## Checking for Existence
Before trying to access a value, you can check if it exists and is not empty using the exists() method. This is
useful for conditional logic.
def user = new Cargo([
name: 'Jeremy',
profile: [
avatar: 'avatar.png'
],
roles: [] // An empty list
])
user.exists('name') // Returns true
user.exists('profile.avatar') // Returns true
user.exists('profile.bio') // Returns false (key does not exist)
user.exists('roles') // Returns false (key exists but the list is empty)
## Adding and Removing Data
You can precisely manage the contents of your Cargo object.
delete(path): Removes a key-value pair at a specific path.clear(): Wipes all data from theCargoobject, leaving it empty.setNext(value): Adds a value using the next available integer as the key. This is useful for creating list-like maps.
def tasks = new Cargo()
// Use setNext to add items with numeric keys
tasks.setNext('Write documentation') // Sets key '1'
tasks.setNext('Review code') // Sets key '2'
println tasks.get('1') // Prints 'Write documentation'
// Delete a specific task
tasks.delete('1')
println tasks.exists('1') // Prints false
// Clear all tasks
tasks.clear()
println tasks.isEmpty() // Prints true
## Working with Lists
For paths that contain lists, you can use append() and remove() to manage their contents without
retrieving and re-setting the entire list.
def user = new Cargo()
user.set('permissions', ['read'])
// Append a single item to the list
user.append('permissions', 'write')
// Append multiple items
user.append('permissions', ['comment', 'delete'])
println user.get('permissions') // Prints ['read', 'write', 'comment', 'delete']
// Remove an item from the list
user.remove('permissions', 'delete')
println user.get('permissions') // Prints ['read', 'write', 'comment']
# Type-Safe Getters in Practice
When you receive data from a form submission or a database, it often arrives as strings or generic objects. Cargo's
type-safe getters ensure you can work with this data reliably without manual parsing and error handling.
Imagine you have a Cargo object populated with data from a client-side request.
## Example: Displaying Product Details in Launchpad
This example shows how to use a Cargo object to hold product information and safely render it in a Launchpad template (.ghtml).
Groovy Logic (in a Source Module or Launchpad Element)
// This Cargo object might be populated from a database or API call.
// Notice the values are strings, just like they might be from an HTTP request.
def productData = new Cargo([
name: 'Spaceport Rocket',
stock: "15",
price: "1299.95",
onSale: "true",
rating: "4.7",
features: "Fuel Efficient, Reusable, Self-Landing"
])
Launchpad Template (product-details.ghtml)
<%
// Assume 'productData' is passed into the template, or otherwise made accessible here.
%>
<div class="product">
<h1>${ productData.getString('name') }</h1>
/// Use getInteger to safely get the stock count for logic
<% if (productData.getInteger('stock') > 0) { %>
/// Use getNumber to handle floating-point values like currency
<p class="price">Price: ${ productData.getNumber('price').money() }</p>
<% } else { %>
<p class="out-of-stock">Out of Stock</p>
<% } %>
/// Use getBool to reliably check a flag
<% if (productData.getBool('onSale')) { %>
<span class="sale-banner">ON SALE!</span>
<% } %>
/// Use getRoundedInteger for display purposes
<p>Rating: ${ productData.getRoundedInteger('rating') } out of 5 stars</p>
<div class="features">
<h3>Features</h3>
<ul>
/// getList can parse a comma-separated string into a List
<% for (feature in productData.getList('features')) { %>
<li>${ feature }</li>
<% } %>
</ul>
</div>
</div>
This template demonstrates how the type-safe getters (getString, getInteger, getNumber, getBool, getList,
getRoundedInteger) allow you to work with the data in its correct type, preventing common errors and making the
template logic clean and robust.
# Handling Default Values
When dealing with potentially incomplete data, providing default values is essential for preventing errors and ensuring
predictable behavior. Cargo offers two distinct methods for this.
## getDefaulted(): Read with Write-Back
The getDefaulted(path, defaultValue) method is perfect for initializing data. It behaves as follows:
1. It checks for a value at the given path.
2. If the value exists, it returns it.
3. If the value does not exist, it writes the defaultValue to that path and then returns it.
This "set-if-not-exists" behavior is extremely useful for setting up default configurations or user profiles the first time they are accessed.
def userSettings = new Cargo()
// The 'theme' key doesn't exist, so getDefaulted sets it to 'light' and returns 'light'.
def theme = userSettings.getDefaulted('theme', 'light')
// Now the key exists.
println userSettings.get('theme') // Prints 'light'
// On the next call, it simply returns the existing value.
def theme2 = userSettings.getDefaulted('theme', 'dark') // Will NOT overwrite
println theme2 // Prints 'light'
## getOrDefault(): Read-Only Fallback
The getOrDefault(path, valueOtherwise) method provides a safe, read-only fallback.
- It checks for a value at the given
path. - If the value exists, it returns it.
- If the value does not exist, it returns
valueOtherwisebut does not modify the Cargo object.
Use this when you need a temporary default for a calculation or display without altering the original data state.
def requestData = new Cargo([ item: 'rocket' ])
// The 'quantity' key doesn't exist, so getOrDefault returns the fallback value '1'.
def quantity = requestData.getOrDefault('quantity', 1)
println quantity // Prints 1
// The original Cargo object remains unchanged.
println requestData.exists('quantity') // Prints false
# Iterating and Querying Cargo
Cargo provides a rich set of methods for inspecting, querying, and transforming its data, making it easy to work with collections of information. These methods allow you to treat a Cargo object much like a standard Groovy collection.
## Core Inspection Methods
These methods allow you to inspect the basic structure and contents of a Cargo object.
size(): Returns the number of top-level entries.keys(): Returns aSetof the top-level keys.values(): Returns aListof the top-level values. Note that if a value is a nestedCargo, theCargoobject itself is returned.entrySet(): Returns aSetofMap.Entry-like objects, each containing akeyandvalue. This is useful when you need to iterate over both at once.map(): Converts theCargoobject's top-level data into a standard GroovyMap.
def users = new Cargo([
user1: [ name: 'Jeremy', level: 99 ],
user2: [ name: 'Ollie', level: 50 ]
])
println users.size() // Prints 2
println users.keys() // Prints ['user1', 'user2']
// values() returns a list of Cargo objects
println users.values().first().getClass().name // prints spaceport.computer.memory.virtual.Cargo
// entrySet() gives you both key and value
users.entrySet().each { entry ->
println "Key: ${entry.key}, Name: ${entry.value.name}"
}
// map() converts it to a standard Map
def userMap = users.map()
println userMap.getClass().name // Prints java.util.LinkedHashMap
## Finding Specific Data
You can search for entries within a Cargo object using closures.
findKey(closure): Returns the first key whose value matches the closure.findAllKeys(closure): Returns aListof all keys whose values match the closure.count(closure): Returns the number of entries that match the closure.
def products = new Cargo([
prodA: [ name: 'Rocket', category: 'transport', stock: 10 ],
prodB: [ name: 'Laser', category: 'weapon', stock: 0 ],
prodC: [ name: 'Shield', category: 'defense', stock: 5 ],
prodD: [ name: 'Blaster', category: 'weapon', stock: 25 ]
])
// Find the key for the first item with 0 stock
def outOfStockKey = products.findKey { it.stock == 0 } // Returns 'prodB'
// Find all keys for items in the 'weapon' category
def weaponKeys = products.findAllKeys { it.category == 'weapon' } // Returns ['prodB', 'prodD']
// Count how many items have stock > 0
def inStockCount = products.count { it.stock > 0 } // Returns 3
## Sorting and Pagination
Cargo includes built-in helpers for sorting and paginating its contents, which is ideal for displaying large data sets.
sort(closure): Sorts the entries based on a closure and returns a sortedListof theCargovalues.paginate(page, size): Splits theCargovalues into pages and returns theListfor the requested page.
def players = new Cargo([
p1: [ name: 'Zoe', score: 1500 ],
p2: [ name: 'Alex', score: 2500 ],
p3: [ name: 'Chloe', score: 1200 ]
])
// Sort players by score, descending
def sortedPlayers = players.sort { a, b -> b.score <=> a.score }
sortedPlayers.each { println it.name } // Prints Alex, Zoe, Chloe
// Paginate the results, showing 2 per page
def firstPage = players.paginate(1, 2)
println firstPage.size() // Prints 2
# Leveraging Groovy's Power
Because Cargo is built on standard Groovy principles, it seamlessly integrates with Groovy's powerful collection methods. Many of these methods are enhanced by Spaceport's Class Enhancements to work intelligently with Cargo.
Here’s a brief overview of how you can use these familiar methods.
each { ... }: Iterate over theCargo's entries.
// Closure gets the value by default
products.each { product -> println product.name }
// Or, get both key and value
products.each { key, product -> println "${key}: ${product.name}" }
collect { ... }: Transform each entry into a new value and return the results as aList.
// Get a list of all product names
def names = products.collect { it.name } // Returns ['Rocket', 'Laser', 'Shield', 'Blaster']
find { ... }andfindAll { ... }: Find the first value or aListof all values that match a closure.
// Find the first product in the 'weapon' category
def firstWeapon = products.find { it.category == 'weapon' }
println firstWeapon.name // Prints 'Laser'
any { ... }andevery { ... }: Check if at least one entry (any) or all entries (every) match a condition.
// Is any product out of stock?
boolean hasOutOfStock = products.any { it.stock == 0 } // Returns true
combine { ... }: A Spaceport enhancement that collects the results of a closure and joins them into a single string. It’s perfect for quick rendering tasks.
// Create a simple HTML list of product names
def productHtml = "<ul>" + products.combine { "<li>${it.name}</li>" } + "</ul>"
# Global State with the Spaceport Store
While Cargo is excellent as a local object, its capabilities expand significantly when used to manage shared, application-wide state. This is achieved by connecting it to the Spaceport Store.
The Spaceport Store (Spaceport.store) is a global, in-memory, thread-safe ConcurrentHashMap that is available everywhere in your application. It's the perfect place to store data that needs to be shared across different requests, user sessions, or source modules, but doesn't need to be permanently saved to the database. Think of it as a central hub for your application's live operational data.
## Cargo.fromStore()
To simplify working with the store, Cargo provides a static helper method: Cargo.fromStore(name). This method acts as a singleton provider for named Cargo objects.
- If a
Cargoobject with the givennamealready exists in the store, it returns that exact same instance. - If it doesn't exist, it creates a new empty
Cargo, places it in the store under thatname, and then returns it.
This ensures that any part of your application asking for Cargo.fromStore('hitCounter') will always receive the same, single object to work with.
## Use Case: Application-Wide Hit Counter
Imagine you want to track the total number of page hits across your entire application. Using Cargo.fromStore() makes this trivial.
Source Module: TrafficManager.groovy
import spaceport.computer.memory.virtual.Cargo
class TrafficManager {
// Get the global hit counter Cargo from the store.
// This will create it on the first run.
static Cargo globalStats = Cargo.fromStore('globalStats')
@Alert('on page hit')
static _countHit(HttpResult r) {
// Any time a page is hit, increment the counter.
// This is the same instance, no matter where it's called from.
globalStats.inc('totalHits')
}
}
Source Module: Api.groovy
import spaceport.computer.memory.virtual.Cargo
class Api {
@Alert('on /api/stats hit')
static _getStats(HttpResult r) {
// Retrieve the same global stats object from the store.
def stats = Cargo.fromStore('globalStats')
// Return the current count as JSON
r.writeToClient([ 'totalApplicationHits': stats.getInteger('totalHits') ])
}
}
Why do this?
- Global Singleton Access: You can easily access a shared state object from anywhere in your application without passing instances around.
- Application-wide State: It's perfect for managing state that transcends a single user or request, like feature flags, simple caches, or global statistics.
- Simplicity: The
fromStore()method handles all the creation and retrieval logic for you.
# Persistent State with Documents
For data that needs to be permanent and survive server restarts, you can mirror a Cargo object to a Spaceport Document. This creates a live link between your in-memory Cargo object and a document in your CouchDB database.
When a Cargo is mirrored, any changes you make to it are automatically and asynchronously saved to the corresponding Document. This combines the convenience of the Cargo API with the power of persistent database storage.
## Cargo.fromDocument()
The static helper method Cargo.fromDocument(document) establishes this link. You pass it a Document instance, and it returns a Cargo object that is directly tied to the cargo property of that document.
## Use Case: Managing Article Metadata
This is a powerful use case for mirrored Cargo objects. While the core content of an article (like its title and body) might be stored in formal Document properties, Cargo is perfect for managing the flexible metadata associated with it, such as view counts, tags, and likes.
Source Module: ArticleHandler.groovy
// Assume ArticleDocument extends spaceport.computer.memory.physical.Document
import documents.ArticleDocument
import spaceport.computer.memory.virtual.Cargo
class ArticleHandler {
// This alert fires when a user views an article page
@Alert('~on /articles/(.*) hit')
static _viewArticle(HttpResult r) {
// 1. Fetch the article's document from the database
def articleDoc = ArticleDocument.fetchById(r.matches[0])
if (!articleDoc) return r.notFound()
// 2. Get a Cargo object mirrored to that document's metadata
def articleMeta = Cargo.fromDocument(articleDoc)
// 3. Update the metadata using the Cargo API
articleMeta.inc('viewCount')
articleMeta.addToSet('tags', 'recently-viewed')
// There's no need to call articleDoc.save()!
// The changes to 'articleMeta' are automatically persisted to the database.
// Now, render the article page...
r.writeToClient("Viewing '${articleDoc.title}'. Views: ${articleMeta.get('viewCount')}")
}
// This alert fires when a user "likes" an article
@Alert('~on /articles/(.*)/like POST')
static _likeArticle(HttpResult r) {
def articleDoc = ArticleDocument.fetchById(r.matches[0])
if (!articleDoc) return r.notFound()
def articleMeta = Cargo.fromDocument(articleDoc)
// Increment the likes and record who liked it
articleMeta.inc('likes')
articleMeta.addToSet('likedBy', r.context.data.getString('userId'))
r.writeToClient([ status: 'ok', likes: articleMeta.getInteger('likes') ])
}
}
Why do this?
* Automatic Persistence: It dramatically simplifies database interactions. You work with a normal Cargo object, and Spaceport handles saving it to CouchDB in the background.
* Data Synchronization: It keeps your in-memory state object in sync with the database, ensuring data integrity.
* Flexible Document Schemas: You can easily manage complex, nested data inside your Documents using the clean Cargo API without having to manually manipulate maps and lists.
# Reactive State with Launchpad
The single most important feature of Cargo is its role as a reactive data source in Launchpad templates. When you modify a Cargo object on the server during a server action, Launchpad can automatically detect the change and push updates to the client's browser without a full page reload.
This is accomplished using Launchpad's server-reactive syntax: ${{ ... }}.
When you wrap a Cargo call in these special brackets, Launchpad creates a subscription. If that Cargo object is later modified by a server action (like an on-click event), Launchpad will re-evaluate the expression and send the new result to the client, updating the UI in real-time.
## Use Case: A Real-Time Counter
Here’s how you can build a simple counter component where the state is managed entirely on the server by a Cargo object, and the UI updates reactively.
Launchpad Template (counter.ghtml)
<%
// Get a Cargo object from the store to hold our counter's state.
// Using the store makes the state persist across different requests.
def counter = Cargo.fromStore('myCounter')
%>
<div class="counter-widget">
<span>
Current Count:
/// This creates a reactive subscription to the counter Cargo.
/// When counter changes, only this part of the DOM will be updated.
${{ counter.counter.getDefaulted('value', 0) }}
</span>
/// This button triggers a server action that modifies the Cargo object
<button on-click=${_ { counter.inc('value') }}>+</button>
</div>
What's Happening?
- The page loads, and
${{ counter.getDefaulted('value', 0) }}renders the initial count. Launchpad now knows this piece of the DOM depends oncounter. - The user clicks the
+button, triggering theon-clickserver action. - The Groovy code
counter.inc('value')runs on the server, modifying theCargoobject in the Spaceport Store. - Launchpad's reactive system detects that
counterhas changed and that the<strong>tag is subscribed to it. - Launchpad automatically sends a tiny, targeted update to the client's browser, telling it to change the content of
#count-displayto the new value.
This creates a rich, interactive experience with server-side state management and minimal boilerplate.
# Other Utilities and Advanced Usage
Here are a few other methods and behaviors that round out Cargo's feature set.
## Groovy Truthiness
Cargo objects can be evaluated as booleans, which is known as "Groovy Truth". An empty Cargo is false, while a Cargo with any data in it is true.
def userPrefs = new Cargo()
if (userPrefs) {
// This code will not run
}
userPrefs.set('theme', 'dark')
if (userPrefs) {
// This code will run
}
## Type Coercion with asType
You can seamlessly convert a Cargo object into other common data structures using the as keyword.
def data = new Cargo([a: 1, b: 2])
// Convert to a Map
Map myMap = data as Map
// Convert to a List (contains the values)
List myValues = data as List // Returns [1, 2]
## Operator Overloading
For single-value Cargo objects used as counters, you can use Groovy's standard ++ and -- operators.
def counter = new Cargo(5)
counter++
println counter.get() // Prints 6
counter--
println counter.get() // Prints 5
## Other Convenience Methods
isEmpty(): Explicitly checks if theCargois empty, returningtrueorfalse.isMirrored(): Returnstrueif theCargois connected to aDocument.toJSON(): Guarantees a JSON string representation of theCargo's data, even if it's empty.toPrettyJSON(): Returns a nicely formatted JSON string for easier reading by humans.first()/last(): Gets the first or last value from theCargo's top-level entries.reverse(): Returns aListof theCargo's entries in reverse order.
# Summary: The Role of Cargo
As we've seen, Cargo is far more than a simple data map. It's a versatile tool that adapts to your application's needs, seamlessly transitioning between four key roles: a temporary data holder, a global state manager, an automatic persistence layer, and a reactive UI driver. By understanding these roles, you can write cleaner, more powerful, and more maintainable Spaceport applications.
## Key Takeaways
- For temporary, request-scoped data, use a local
def myCargo = new Cargo(). - For shared, application-wide state that doesn't need to be saved (like caches or stats), use
Cargo.fromStore(). - For data that must be persisted to the database, mirror it to a
DocumentwithCargo.fromDocument(). - To create dynamic, real-time user interfaces, connect your
Cargoobjects to your frontend using Launchpad's reactive${{...}}syntax.
## Next Steps
With a solid understanding of Cargo, you are now ready to see how it integrates with the other powerful systems in Spaceport. Explore the following documentation to continue building dynamic, data-driven applications:
- Documents: Dive deeper into how Spaceport manages persistent data with CouchDB.
- Launchpad: Learn more about the server-side templating and component system that
Cargo's reactivity brings to life. - Server Elements: See how to build reusable components that often use
Cargofor internal state management. - Transmissions: Understand the server actions that modify
Cargoand trigger its reactive updates.
SPACEPORT DOCS