OutLayer Documentation
Persistent Storage
OutLayer provides encrypted persistent storage for your WASM projects. Data survives across executions and version updates, with automatic user isolation and atomic operations for concurrent-safe updates.
Requires Projects: Storage is only available for code deployed as a Project. You must use WASI Preview 2 (wasm32-wasip2) build target.
Overview#
All versions of a project share the same storage namespace. Data written by v1 is readable by v2. Storage is encrypted using the keystore TEE and isolated per user.
Encrypted
All data encrypted with project-specific keys in TEE
User-Isolated
Each user has their own namespace, automatic isolation
Atomic Operations
Increment, decrement, compare-and-swap for concurrency
Quick Start#
use outlayer::{metadata, storage};
// REQUIRED: declare which project this code belongs to
metadata! {
project: "alice.near/my-app",
version: "1.0.0",
}
fn main() {
// Store data
storage::set("counter", &42i64.to_le_bytes()).unwrap();
// Read data
if let Some(data) = storage::get("counter").unwrap() {
let value = i64::from_le_bytes(data.try_into().unwrap());
println!("Counter: {}", value);
}
// Atomic increment
let new_value = storage::increment("visits", 1).unwrap();
println!("Visit count: {}", new_value);
}Critical: The project in metadata must exactly match your project ID on the contract. If they don't match, storage operations will fail.
Storage API#
The storage interface is defined in worker/wit/world.wit and imported as near:rpc/[email protected]:
interface storage {
// Basic operations
set: func(key: string, value: list<u8>) -> string;
get: func(key: string) -> tuple<list<u8>, string>;
has: func(key: string) -> bool;
delete: func(key: string) -> bool;
list-keys: func(prefix: string) -> tuple<string, string>;
// Conditional writes (atomic operations)
set-if-absent: func(key: string, value: list<u8>) -> tuple<bool, string>;
set-if-equals: func(key: string, expected: list<u8>, new-value: list<u8>) -> tuple<bool, list<u8>, string>;
increment: func(key: string, delta: s64) -> tuple<s64, string>;
decrement: func(key: string, delta: s64) -> tuple<s64, string>;
// Worker-private storage (shared across all users)
set-worker: func(key: string, value: list<u8>) -> string;
get-worker: func(key: string) -> tuple<list<u8>, string>;
// Version migration
get-by-version: func(key: string, wasm-hash: string) -> tuple<list<u8>, string>;
// Cleanup
clear-all: func() -> string;
clear-version: func(wasm-hash: string) -> string;
}Methods Reference#
| Method | Description | Returns |
|---|---|---|
| set(key, value) | Store a key-value pair | Error string (empty on success) |
| get(key) | Retrieve value by key | (data, error) |
| has(key) | Check if key exists | bool |
| delete(key) | Delete a key | bool (true if existed) |
| list-keys(prefix) | List keys with prefix | (JSON array string, error) |
| set-if-absent(key, value) | Set only if key doesn't exist | (inserted: bool, error) |
| set-if-equals(key, expected, new) | Compare-and-swap (atomic update) | (success, current, error) |
| increment(key, delta) | Atomic increment (i64) | (new_value: i64, error) |
| decrement(key, delta) | Atomic decrement (i64) | (new_value: i64, error) |
| set-worker(key, value) | Store worker-private data | Error string |
| get-worker(key) | Get worker-private data | (data, error) |
Atomic Operations#
Use atomic operations for concurrent-safe updates. These are essential for counters, rate limiters, and any state that multiple executions might modify simultaneously.
Why use atomic operations? Regular set() can cause race conditions when multiple users execute simultaneously. Atomic operations ensure data integrity.
use outlayer::storage;
// ============ set_if_absent ============
// Only inserts if key doesn't exist - perfect for initialization
if storage::set_if_absent("counter", &0i64.to_le_bytes())? {
println!("Counter initialized to 0");
} else {
println!("Counter already exists");
}
// ============ increment / decrement ============
// Atomic counters - handles concurrent updates automatically
let views = storage::increment("page_views", 1)?;
println!("Page views: {}", views);
let stock = storage::decrement("stock:item_123", 1)?;
if stock < 0 {
println!("Out of stock!");
}
// ============ set_if_equals (Compare-and-Swap) ============
// Update only if current value matches expected
let current = storage::get("balance")?.unwrap_or(vec![0; 8]);
let balance = i64::from_le_bytes(current.clone().try_into().unwrap());
let new_balance = balance + 100;
match storage::set_if_equals("balance", ¤t, &new_balance.to_le_bytes())? {
(true, _) => println!("Balance updated!"),
(false, Some(actual)) => println!("Concurrent update! Retry with {:?}", actual),
(false, None) => println!("Key was deleted"),
}Important: increment/decrement expect values stored as 8-byte little-endian i64. If you store counters differently (e.g., as string), use set_if_equals instead.
User Data Isolation#
Storage is automatically isolated per user at the protocol level. Each user has their own namespace:
- Automatic isolation:
alice.nearcannot read data stored bybob.near - Per-user encryption: Different encryption keys for each user's data
- Transparent to WASM: Your code uses simple keys like
balance- the platform handles namespacing - Caller-triggered access: WASM can only read user data when that user triggers the execution
// alice.near calls execution:
storage::set("balance", b"100");
// Database key: project_uuid:alice.near:balance = "100"
// bob.near calls execution:
storage::set("balance", b"200");
// Database key: project_uuid:bob.near:balance = "200"
// alice.near reads:
storage::get("balance") // -> "100" (her data)
// bob.near reads:
storage::get("balance") // -> "200" (his data)
// WASM code CANNOT read another user's data!Worker Storage (Shared State)#
Worker-private storage uses @worker as account_id, making it shared across all users. Users cannot directly access this storage - only your WASM code can read/write it.
// Any user calls execution:
storage::set_worker("total_count", b"100");
// Database key: project_uuid:@worker:total_count = "100"
// Any other user calls:
storage::get_worker("total_count") // -> "100" (same shared data!)
// Use case: Private Token balances
storage::set_worker("balances", balances_json); // Shared across all users
// Users cannot directly read balances - only through your WASM methodsUse case: Worker storage is ideal for shared state like token balances, global counters, or application configuration. Users interact with this data only through your WASM methods - they cannot bypass your logic.
Security#
- All data is encrypted using keystore TEE before storage
- Encryption key derived from:
storage:{project_uuid}:{account_id} - Worker-private storage uses
@workeras account_id - Data is automatically deleted when project is deleted
Warning: Deleting a project permanently removes all storage data. This action cannot be undone. Export any important data before deletion.
Use Cases#
User Preferences
Store settings, themes, language preferences per user
Counters & Analytics
Page views, API calls, usage metrics with atomic increments
Caching
Cache expensive computation results for subsequent calls
Session Data
Store temporary state between executions
Private Tokens
Use worker storage for token balances users cannot directly access
Rate Limiting
Track API calls per user with atomic counters
Best Practices#
- Use WASI Preview 2 (wasm32-wasip2) build target
- Use key prefixes for organization (e.g.,
user:alice:settings) - Use
increment/decrementfor counters instead of get+set - Use
set_if_absentfor one-time initialization - Document your key format for future data migrations
- Use
get_by_versionwhen migrating data between versions
Related Documentation
- Projects & Versions - Create and manage projects for storage access
- Building OutLayer App - WASI P1 vs P2, building WASM modules
- Examples - See private-token-ark for storage usage